From 7477ed0f3d65fed34633d6fe229c5adf2cb6c99b Mon Sep 17 00:00:00 2001 From: TiA4f8R <74829229+TiA4f8R@users.noreply.github.com> Date: Sun, 6 Mar 2022 20:33:19 +0100 Subject: [PATCH] [YouTube] Add ability to generate manifests of progressive, OTF and post live streams A new class has been added to do so: YoutubeDashManifestCreator. It relies on a new class: ManifestCreatorCache, to cache the content, which relies on a new pair class named Pair. Results are cached and there is a cache per delivery type, on which cache limit, clear factor, clearing and resetting can be applied to each cache and to all caches. Look at code changes for more details. --- .../youtube/YoutubeDashManifestCreator.java | 1887 +++++++++++++++++ .../extractor/utils/ManifestCreatorCache.java | 301 +++ 2 files changed, 2188 insertions(+) create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeDashManifestCreator.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/utils/ManifestCreatorCache.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeDashManifestCreator.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeDashManifestCreator.java new file mode 100644 index 000000000..62d833f51 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeDashManifestCreator.java @@ -0,0 +1,1887 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.downloader.Downloader; +import org.schabi.newpipe.extractor.downloader.Response; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.utils.ManifestCreatorCache; +import org.schabi.newpipe.extractor.utils.Utils; +import org.w3c.dom.Attr; +import org.w3c.dom.DOMException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import javax.annotation.Nonnull; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.*; + +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.*; +import static org.schabi.newpipe.extractor.utils.Utils.*; + +/** + * Class to generate DASH manifests from YouTube OTF, progressive and ended/post-live-DVR streams. + * + *

+ * It relies on external classes from the {@link org.w3c.dom} and {@link javax.xml} packages. + *

+ */ +@SuppressWarnings({"ConstantConditions", "unused"}) +public final class YoutubeDashManifestCreator { + + /** + * URL parameter of the first sequence for live, post-live-DVR and OTF streams. + */ + private static final String SQ_0 = "&sq=0"; + + /** + * URL parameter of the first stream request made by official clients. + */ + private static final String RN_0 = "&rn=0"; + + /** + * URL parameter specific to web clients. When this param is added, if a redirection occurs, + * the server will not redirect clients to the redirect URL. Instead, it will provide this URL + * as the response body. + */ + private static final String ALR_YES = "&alr=yes"; + + /** + * The redirect count limit that this class uses, which is the same limit as OkHttp. + */ + private static final int MAXIMUM_REDIRECT_COUNT = 20; + + /** + * A list of durations of segments of an OTF stream. + * + *

+ * This list is automatically cleared in the execution of + * {@link #createDashManifestFromOtfStreamingUrl(String, ItagItem, long)}, before the DASH + * manifest is converted to a string. + *

+ */ + private static final List SEGMENTS_DURATION = new ArrayList<>(); + + /** + * A list of contiguous repetitions of durations of an OTF stream. + * + *

+ * This list is automatically cleared in the execution of + * {@link #createDashManifestFromOtfStreamingUrl(String, ItagItem, long)}, before the DASH + * manifest is converted to a string. + *

+ */ + private static final List DURATION_REPETITIONS = new ArrayList<>(); + + /** + * Cache of DASH manifests generated for OTF streams. + */ + private static final ManifestCreatorCache GENERATED_OTF_MANIFESTS = + new ManifestCreatorCache<>(); + + /** + * Cache of DASH manifests generated for post-live-DVR streams. + */ + private static final ManifestCreatorCache + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS = new ManifestCreatorCache<>(); + + /** + * Cache of DASH manifests generated for progressive streams. + */ + private static final ManifestCreatorCache + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS = new ManifestCreatorCache<>(); + + /** + * Enum of streaming format types used by YouTube in their streams. + */ + private enum DeliveryType { + + /** + * YouTube's progressive delivery method, which works with HTTP range headers. + * (Note that official clients use the corresponding parameter instead.) + * + *

+ * Initialization and index ranges are available to get metadata (the corresponding values + * are returned in the player response). + *

+ */ + PROGRESSIVE, + /** + * YouTube's OTF delivery method which uses a sequence parameter to get segments of + * streams. + * + *

+ * The first sequence (which can be fetched with the {@link #SQ_0} param) contains all the + * metadata needed to build the stream source (sidx boxes, segment length, segment count, + * duration, ...) + *

+ *

+ * Only used for videos; mostly those with a small amount of views, or ended livestreams + * which have just been re-encoded as normal videos. + *

+ */ + OTF, + /** + * YouTube's delivery method for livestreams which uses a sequence parameter to get + * segments of streams. + * + *

+ * Each sequence (which can be fetched with the {@link #SQ_0} param) contains its own + * metadata (sidx boxes, segment length, ...), which make no need of an initialization + * segment. + *

+ *

+ * Only used for livestreams (ended or running). + *

+ */ + LIVE + } + + private YoutubeDashManifestCreator() { + } + + /** + * Exception that is thrown when the {@link YoutubeDashManifestCreator} encounters a problem + * while creating a manifest. + */ + public static final class YoutubeDashManifestCreationException extends Exception { + + YoutubeDashManifestCreationException(final String message) { + super(message); + } + + YoutubeDashManifestCreationException(final String message, final Exception e) { + super(message, e); + } + } + + /** + * Create DASH manifests from a YouTube OTF stream. + * + *

+ * OTF streams are YouTube-DASH specific streams which work with sequences and without the need + * to get a manifest (even if one is provided, it is not used by official clients). + *

+ *

+ * They can be found only on videos; mostly those with a small amount of views, or ended + * livestreams which have just been re-encoded as normal videos. + *

+ * + *

This method needs: + *

    + *
  • the base URL of the stream (which, if you try to access to it, returns HTTP + * status code 404 after redirects, and if the URL is valid);
  • + *
  • an {@link ItagItem}, which needs to contain the following information: + *
      + *
    • its type (see {@link ItagItem.ItagType}), to identify if the content is + * an audio or a video stream;
    • + *
    • its bitrate;
    • + *
    • its mime type;
    • + *
    • its codec(s);
    • + *
    • for an audio stream: its audio channels;
    • + *
    • for a video stream: its width and height.
    • + *
    + *
  • + *
  • the duration of the video, which will be used if the duration could not be + * parsed from the first sequence of the stream.
  • + *
+ *

+ * + *

In order to generate the DASH manifest, this method will: + *

    + *
  • request the first sequence of the stream (the base URL on which the first + * sequence parameters are appended (see {@link #RN_0} and {@link #SQ_0})) with a POST + * or GET request (depending of the client on which the streaming URL comes from); + *
  • + *
  • follow its redirection(s), if any;
  • + *
  • save the last URL, remove the first sequence parameters;
  • + *
  • use the information provided in the {@link ItagItem} to generate all + * elements of the DASH manifest.
  • + *
+ *

+ * + *

+ * If the duration cannot be extracted, the {@code durationSecondsFallback} value will be used + * as the stream duration. + *

+ * + * @param otfBaseStreamingUrl the base URL of the OTF stream, which cannot be null + * @param itagItem the {@link ItagItem} corresponding to the stream, which + * cannot be null + * @param durationSecondsFallback the duration of the video, which will be used if the duration + * could not be extracted from the first sequence + * @return the manifest generated into a string + * @throws YoutubeDashManifestCreationException if something goes wrong when trying to generate + * the DASH manifest + */ + @Nonnull + public static String createDashManifestFromOtfStreamingUrl( + @Nonnull String otfBaseStreamingUrl, + @Nonnull final ItagItem itagItem, + final long durationSecondsFallback) + throws YoutubeDashManifestCreationException { + if (GENERATED_OTF_MANIFESTS.containsKey(otfBaseStreamingUrl)) { + return GENERATED_OTF_MANIFESTS.get(otfBaseStreamingUrl).getSecond(); + } + + final String originalOtfBaseStreamingUrl = otfBaseStreamingUrl; + // Try to avoid redirects when streaming the content by saving the last URL we get + // from video servers. + final Response response = getInitializationResponse(otfBaseStreamingUrl, + itagItem, DeliveryType.OTF); + otfBaseStreamingUrl = response.latestUrl().replace(SQ_0, EMPTY_STRING) + .replace(RN_0, EMPTY_STRING).replace(ALR_YES, EMPTY_STRING); + + final int responseCode = response.responseCode(); + if (responseCode != 200) { + throw new YoutubeDashManifestCreationException( + "Unable to create the DASH manifest: could not get the initialization URL of the OTF stream: response code " + + responseCode); + } + + final String[] segmentDuration; + + try { + final String[] segmentsAndDurationsResponseSplit = response.responseBody() + // Get the lines with the durations and the following + .split("Segment-Durations-Ms: ")[1] + // Remove the other lines + .split("\n")[0] + // Get all durations and repetitions which are separated by a comma + .split(","); + final int lastIndex = segmentsAndDurationsResponseSplit.length - 1; + if (isBlank(segmentsAndDurationsResponseSplit[lastIndex])) { + segmentDuration = Arrays.copyOf(segmentsAndDurationsResponseSplit, lastIndex); + } else { + segmentDuration = segmentsAndDurationsResponseSplit; + } + } catch (final Exception e) { + throw new YoutubeDashManifestCreationException( + "Unable to generate the DASH manifest: could not get the duration of segments", e); + } + + final Document document = generateDocumentAndMpdElement(segmentDuration, DeliveryType.OTF, + itagItem, durationSecondsFallback); + generatePeriodElement(document); + generateAdaptationSetElement(document, itagItem); + generateRoleElement(document); + generateRepresentationElement(document, itagItem); + if (itagItem.itagType == ItagItem.ItagType.AUDIO) { + generateAudioChannelConfigurationElement(document, itagItem); + } + generateSegmentTemplateElement(document, otfBaseStreamingUrl, DeliveryType.OTF); + generateSegmentTimelineElement(document); + collectSegmentsData(segmentDuration); + generateSegmentElementsForOtfStreams(document); + + SEGMENTS_DURATION.clear(); + DURATION_REPETITIONS.clear(); + + return buildResult(originalOtfBaseStreamingUrl, document, GENERATED_OTF_MANIFESTS); + } + + /** + * Create DASH manifests from a YouTube post-live-DVR stream/ended livestream. + * + *

+ * Post-live-DVR streams/ended livestreams are one of the YouTube DASH specific streams which + * works with sequences and without the need to get a manifest (even if one is provided but not + * used by main clients (and is complete for big ended livestreams because it doesn't return + * the full stream)). + *

+ * + *

+ * They can be found only on livestreams which have ended very recently (a few hours, most of + * the time) + *

+ * + *

This method needs: + *

    + *
  • the base URL of the stream (which, if you try to access to it, returns HTTP + * status code 404 after redirects, and if the URL is valid);
  • + *
  • an {@link ItagItem}, which needs to contain the following information: + *
      + *
    • its type (see {@link ItagItem.ItagType}), to identify if the content is + * an audio or a video stream;
    • + *
    • its bitrate;
    • + *
    • its mime type;
    • + *
    • its codec(s);
    • + *
    • for an audio stream: its audio channels;
    • + *
    • for a video stream: its width and height.
    • + *
    + *
  • + *
  • the duration of the video, which will be used if the duration could not be + * parsed from the first sequence of the stream.
  • + *
+ *

+ * + *

In order to generate the DASH manifest, this method will: + *

    + *
  • request the first sequence of the stream (the base URL on which the first + * sequence parameters are appended (see {@link #RN_0} and {@link #SQ_0})) with a POST + * or GET request (depending of the client on which the streaming URL comes from); + *
  • + *
  • follow its redirection(s), if any;
  • + *
  • save the last URL, remove the first sequence parameters;
  • + *
  • use the information provided in the {@link ItagItem} to generate all + * elements of the DASH manifest.
  • + *
+ *

+ * + *

+ * If the duration cannot be extracted, the {@code durationSecondsFallback} value will be used + * as the stream duration. + *

+ * + * @param postLiveStreamDvrStreamingUrl the base URL of the post-live-DVR stream/ended + * livestream, which cannot be null + * @param itagItem the {@link ItagItem} corresponding to the stream, which + * cannot be null + * @param targetDurationSec the target duration of each sequence, in seconds (this + * value is returned with the targetDurationSec field for + * each stream in YouTube player response) + * @param durationSecondsFallback the duration of the ended livestream which will be used + * if the duration could not be extracted from the first + * sequence + * @return the manifest generated into a string + * @throws YoutubeDashManifestCreationException if something goes wrong when trying to generate + * the DASH manifest + */ + @Nonnull + public static String createDashManifestFromPostLiveStreamDvrStreamingUrl( + @Nonnull String postLiveStreamDvrStreamingUrl, + @Nonnull final ItagItem itagItem, + final int targetDurationSec, + final long durationSecondsFallback) + throws YoutubeDashManifestCreationException { + if (GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.containsKey(postLiveStreamDvrStreamingUrl)) { + return GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.get(postLiveStreamDvrStreamingUrl) + .getSecond(); + } + final String originalPostLiveStreamDvrStreamingUrl = postLiveStreamDvrStreamingUrl; + final String streamDuration; + final String segmentCount; + + if (targetDurationSec <= 0) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: the targetDurationSec value is less than or equal to 0 (" + targetDurationSec + ")"); + } + + try { + // Try to avoid redirects when streaming the content by saving the latest URL we get + // from video servers. + final Response response = getInitializationResponse(postLiveStreamDvrStreamingUrl, + itagItem, DeliveryType.LIVE); + postLiveStreamDvrStreamingUrl = response.latestUrl().replace(SQ_0, EMPTY_STRING) + .replace(RN_0, EMPTY_STRING).replace(ALR_YES, EMPTY_STRING); + + final int responseCode = response.responseCode(); + if (responseCode != 200) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: could not get the initialization URL of the post-live-DVR stream: response code " + + responseCode); + } + + final Map> responseHeaders = response.responseHeaders(); + streamDuration = responseHeaders.get("X-Head-Time-Millis").get(0); + segmentCount = responseHeaders.get("X-Head-Seqnum").get(0); + } catch (final IndexOutOfBoundsException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: could not get the value of the X-Head-Time-Millis or the X-Head-Seqnum header of the post-live-DVR streaming URL", e); + } + + if (isNullOrEmpty(segmentCount)) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: could not get the number of segments of the post-live-DVR stream"); + } + + final Document document = generateDocumentAndMpdElement(new String[]{streamDuration}, + DeliveryType.LIVE, itagItem, durationSecondsFallback); + generatePeriodElement(document); + generateAdaptationSetElement(document, itagItem); + generateRoleElement(document); + generateRepresentationElement(document, itagItem); + if (itagItem.itagType == ItagItem.ItagType.AUDIO) { + generateAudioChannelConfigurationElement(document, itagItem); + } + generateSegmentTemplateElement(document, postLiveStreamDvrStreamingUrl, DeliveryType.LIVE); + generateSegmentTimelineElement(document); + generateSegmentElementForPostLiveDvrStreams(document, targetDurationSec, segmentCount); + + return buildResult(originalPostLiveStreamDvrStreamingUrl, document, + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS); + } + + /** + * Create DASH manifests from a YouTube progressive stream. + * + *

+ * Progressive streams are YouTube DASH streams which work with range requests and without the + * need to get a manifest. + *

+ * + *

+ * They can be found on all videos, and for all streams for most of videos which come from a + * YouTube partner, and on videos with a large number of views. + *

+ * + *

This method needs: + *

    + *
  • the base URL of the stream (which, if you try to access to it, returns the whole + * stream, after redirects, and if the URL is valid);
  • + *
  • an {@link ItagItem}, which needs to contain the following information: + *
      + *
    • its type (see {@link ItagItem.ItagType}), to identify if the content is + * an audio or a video stream;
    • + *
    • its bitrate;
    • + *
    • its mime type;
    • + *
    • its codec(s);
    • + *
    • for an audio stream: its audio channels;
    • + *
    • for a video stream: its width and height.
    • + *
    + *
  • + *
  • the duration of the video, which will be used if the duration could not be + * parsed from the {@link ItagItem}.
  • + *
+ *

+ * + *

In order to generate the DASH manifest, this method will: + *

    + *
  • request the base URL of the stream with a HEAD request;
  • + *
  • follow its redirection(s), if any;
  • + *
  • save the last URL;
  • + *
  • use the information provided in the {@link ItagItem} to generate all + * elements of the DASH manifest.
  • + *
+ *

+ * + *

+ * If the duration cannot be extracted, the {@code durationSecondsFallback} value will be used + * as the stream duration. + *

+ * + * @param progressiveStreamingBaseUrl the base URL of the progressive stream, which cannot be + * null + * @param itagItem the {@link ItagItem} corresponding to the stream, which + * cannot be null + * @param durationSecondsFallback the duration of the progressive stream which will be used + * if the duration could not be extracted from the first + * sequence + * @return the manifest generated into a string + * @throws YoutubeDashManifestCreationException if something goes wrong when trying to generate + * the DASH manifest + */ + @Nonnull + public static String createDashManifestFromProgressiveStreamingUrl( + @Nonnull String progressiveStreamingBaseUrl, + @Nonnull final ItagItem itagItem, + final long durationSecondsFallback) throws YoutubeDashManifestCreationException { + if (GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.containsKey(progressiveStreamingBaseUrl)) { + return GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.get(progressiveStreamingBaseUrl) + .getSecond(); + } + + if (durationSecondsFallback <= 0) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: the durationSecondsFallback value is less than or equal to 0 (" + durationSecondsFallback + ")"); + } + + final Document document = generateDocumentAndMpdElement(new String[]{}, + DeliveryType.PROGRESSIVE, itagItem, durationSecondsFallback); + generatePeriodElement(document); + generateAdaptationSetElement(document, itagItem); + generateRoleElement(document); + generateRepresentationElement(document, itagItem); + if (itagItem.itagType == ItagItem.ItagType.AUDIO) { + generateAudioChannelConfigurationElement(document, itagItem); + } + generateBaseUrlElement(document, progressiveStreamingBaseUrl); + generateSegmentBaseElement(document, itagItem); + generateInitializationElement(document, itagItem); + + return buildResult(progressiveStreamingBaseUrl, document, GENERATED_PROGRESSIVE_STREAMS_MANIFESTS); + } + + /** + * Get the "initialization" {@link Response response} of a stream. + * + *

+ * This method fetches: + *

    + *
  • for progressive streams, the base URL of the stream with a HEAD request;
  • + *
  • for OTF streams and for post-live-DVR streams, the base URL of the stream, to which + * are appended {@link #SQ_0} and {@link #RN_0} params, with a GET request for streaming + * URLs from the WEB client and a POST request for the ones from the Android client;
  • + *
  • for streaming URLs from the WEB client, the {@link #ALR_YES} param is also added. + *
  • + *
+ *

+ * + * @param baseStreamingUrl the base URL of the stream, which cannot be null + * @param itagItem the {@link ItagItem} of stream, which cannot be null + * @param deliveryType the {@link DeliveryType} of the stream + * @return the "initialization" response, without redirections on the network on which the + * request(s) is/are made + * @throws YoutubeDashManifestCreationException if something goes wrong when fetching the + * "initialization" response and/or its redirects + */ + @Nonnull + private static Response getInitializationResponse(@Nonnull String baseStreamingUrl, + @Nonnull final ItagItem itagItem, + final DeliveryType deliveryType) + throws YoutubeDashManifestCreationException { + final boolean isAWebStreamingUrl = isWebStreamingUrl(baseStreamingUrl); + final boolean isAnAndroidStreamingUrl = isAndroidStreamingUrl(baseStreamingUrl); + final boolean isAnAndroidStreamingUrlAndAPostLiveDvrStream = isAnAndroidStreamingUrl + && deliveryType == DeliveryType.LIVE; + if (isAWebStreamingUrl) { + baseStreamingUrl += ALR_YES; + } + baseStreamingUrl = appendRnParamAndSqParamIfNeeded(baseStreamingUrl, deliveryType); + + final Downloader downloader = NewPipe.getDownloader(); + if (isAWebStreamingUrl) { + final String mimeTypeExpected = itagItem.getMediaFormat().getMimeType(); + if (!isNullOrEmpty(mimeTypeExpected)) { + return getStreamingWebUrlWithoutRedirects(downloader, baseStreamingUrl, + mimeTypeExpected, deliveryType); + } + } else if (isAnAndroidStreamingUrlAndAPostLiveDvrStream) { + try { + final Map> headers = new HashMap<>(); + headers.put("User-Agent", Collections.singletonList( + getYoutubeAndroidAppUserAgent(null))); + final byte[] emptyBody = "".getBytes(StandardCharsets.UTF_8); + return downloader.post(baseStreamingUrl, headers, emptyBody); + } catch (final IOException | ExtractionException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: error when trying to get the ANDROID streaming post-live-DVR URL response", e); + } + } + + try { + final Map> headers = new HashMap<>(); + if (isAnAndroidStreamingUrl) { + headers.put("User-Agent", Collections.singletonList( + getYoutubeAndroidAppUserAgent(null))); + } + + return downloader.get(baseStreamingUrl, headers); + } catch (final IOException | ExtractionException e) { + if (isAnAndroidStreamingUrl) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: error when trying to get the ANDROID streaming URL response", e); + } else { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: error when trying to get the streaming URL response", e); + } + } + } + + /** + * Append {@link #SQ_0} for post-live-DVR and OTF streams and {@link #RN_0} to all streams. + * + * @param baseStreamingUrl the base streaming URL to which the param(s) are being appended + * @param deliveryType the {@link DeliveryType} of the stream + * @return the base streaming URL to which the param(s) are appended, depending on the + * {@link DeliveryType} of the stream + */ + @Nonnull + private static String appendRnParamAndSqParamIfNeeded( + @Nonnull String baseStreamingUrl, + @Nonnull final DeliveryType deliveryType) { + if (deliveryType != DeliveryType.PROGRESSIVE) { + baseStreamingUrl += SQ_0; + } + return baseStreamingUrl + RN_0; + } + + /** + * Get a URL on which no redirection between playback hosts should be present on the network + * and/or IP used to fetch the streaming URL. + * + *

+ * This method will follow redirects for web clients, which works in the following way: + *

    + *
  1. the {@link #ALR_YES} param is appended to all streaming URLs
  2. + *
  3. if no redirection occurs, the video server will return the streaming data;
  4. + *
  5. if a redirection occurs, the server will respond with HTTP status code 200 and a + * text/plain mime type. The redirection URL is the response body;
  6. + *
  7. the redirection URL is requested and the steps above from step 2 are repeated (until + * too many redirects are reached, of course).
  8. + *
+ *

+ * + * @param downloader the {@link Downloader} instance to be used + * @param streamingUrl the streaming URL which we are trying to get a streaming URL + * without any redirection on the network and/or IP used + * @param responseMimeTypeExpected the response mime type expected from Google video servers + * @param deliveryType the {@link DeliveryType} of the stream + * @return the response of the stream which should have no redirections + * @throws YoutubeDashManifestCreationException if something goes wrong when trying to get the + * response without any redirection + */ + @Nonnull + private static Response getStreamingWebUrlWithoutRedirects( + @Nonnull final Downloader downloader, + @Nonnull String streamingUrl, + @Nonnull final String responseMimeTypeExpected, + @Nonnull final DeliveryType deliveryType) throws YoutubeDashManifestCreationException { + try { + final Map> headers = new HashMap<>(); + addClientInfoHeaders(headers); + + String responseMimeType = ""; + + int redirectsCount = 0; + while (!responseMimeType.equals(responseMimeTypeExpected) + && redirectsCount < MAXIMUM_REDIRECT_COUNT) { + final Response response; + // We can use head requests to reduce the request size, but only for progressive + // streams + if (deliveryType == DeliveryType.PROGRESSIVE) { + response = downloader.head(streamingUrl, headers); + } else { + response = downloader.get(streamingUrl, headers); + } + + final int responseCode = response.responseCode(); + if (responseCode != 200) { + if (deliveryType == DeliveryType.LIVE) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: could not get the initialization URL of the post-live-DVR stream: response code " + + responseCode); + } else if (deliveryType == DeliveryType.OTF) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: could not get the initialization URL of the OTF stream: response code " + + responseCode); + } else { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: could not fetch the URL of the progressive stream: response code " + + responseCode); + } + } + + // A valid response must include a Content-Type header, so we can require that + // the response from video servers has this header. + try { + responseMimeType = Objects.requireNonNull(response.getHeader( + "Content-Type")); + } catch (final NullPointerException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: could not get the Content-Type header from the streaming URL", e); + } + + // The response body is the redirection URL + if (responseMimeType.equals("text/plain")) { + streamingUrl = response.responseBody(); + redirectsCount++; + } else { + return response; + } + } + + if (redirectsCount >= MAXIMUM_REDIRECT_COUNT) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: too many redirects when trying to get the WEB streaming URL response"); + } + + // This should never be reached, but is required because we don't want to return null + // here + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: error when trying to get the WEB streaming URL response"); + } catch (final IOException | ExtractionException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: error when trying to get the WEB streaming URL response", e); + } + } + + /** + * Collect all segments from an OTF stream, by parsing the string array which contains all the + * sequences. + * + * @param segmentDuration the string array which contains all the sequences extracted with the + * regular expression + * @throws YoutubeDashManifestCreationException if something goes wrong when trying to collect + * the segments of the OTF stream + */ + private static void collectSegmentsData(@Nonnull final String[] segmentDuration) + throws YoutubeDashManifestCreationException { + try { + for (final String segDuration : segmentDuration) { + final String[] segmentLengthRepeat = segDuration.split("\\(r="); + int segmentRepeatCount = 0; + // There are repetitions of a segment duration in other segments + if (segmentLengthRepeat.length > 1) { + segmentRepeatCount = Integer.parseInt(Utils.removeNonDigitCharacters( + segmentLengthRepeat[1])); + } + final int segmentLength = Integer.parseInt(segmentLengthRepeat[0]); + SEGMENTS_DURATION.add(segmentLength); + DURATION_REPETITIONS.add(segmentRepeatCount); + } + } catch (final NumberFormatException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: unable to get the segments of the stream", e); + } + } + + /** + * Get the duration of an OTF stream. + * + *

+ * The duration of OTF streams is not returned into the player response and needs to be + * calculated by adding the duration of each segment. + *

+ * + * @param segmentDuration the segment duration object extracted from the initialization + * sequence of the stream + * @return the duration of the OTF stream + * @throws YoutubeDashManifestCreationException if something goes wrong when parsing the + * {@code segmentDuration} object + */ + private static int getStreamDuration(@Nonnull final String[] segmentDuration) + throws YoutubeDashManifestCreationException { + try { + int streamLengthMs = 0; + for (final String segDuration : segmentDuration) { + final String[] segmentLengthRepeat = segDuration.split("\\(r="); + int segmentRepeatCount = 0; + // There are repetitions of a segment duration in other segments + if (segmentLengthRepeat.length > 1) { + segmentRepeatCount = Integer.parseInt(Utils.removeNonDigitCharacters( + segmentLengthRepeat[1])); + } + final int segmentLength = Integer.parseInt(segmentLengthRepeat[0]); + streamLengthMs += segmentLength + segmentRepeatCount * segmentLength; + } + return streamLengthMs; + } catch (final NumberFormatException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: unable to get the length of the stream", e); + } + } + + /** + * Create a {@link Document} object and generate the {@code } element of the manifest. + * + *

+ * The generated {@code } element looks like the manifest returned into the player + * response of videos with OTF streams: + *

+ *

+ * {@code } + * (where {@code $duration$} represents the duration in seconds (a number with 3 digits after + * the decimal point) + *

+ *

+ * If the duration is an integer or a double with less than 3 digits after the decimal point, + * it will be converted into a double with 3 digits after the decimal point. + *

+ * + * @param segmentDuration the segment duration object extracted from the initialization + * sequence of the stream + * @param deliveryType the {@link DeliveryType} of the stream, see the enum for + * possible values + * @param durationSecondsFallback the duration in seconds, extracted from player response, used + * as a fallback + * @return a {@link Document} object which contains a {@code } element + * @throws YoutubeDashManifestCreationException if something goes wrong when generating/ + * appending the {@link Document object} or the + * {@code } element + */ + private static Document generateDocumentAndMpdElement(@Nonnull final String[] segmentDuration, + final DeliveryType deliveryType, + @Nonnull final ItagItem itagItem, + final long durationSecondsFallback) + throws YoutubeDashManifestCreationException { + final DocumentBuilderFactory documentBuilderFactory; + final DocumentBuilder documentBuilder; + final Document document; + try { + documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilder = documentBuilderFactory.newDocumentBuilder(); + document = documentBuilder.newDocument(); + + final Element mpdElement = document.createElement("MPD"); + document.appendChild(mpdElement); + + final Attr xmlnsXsiAttribute = document.createAttribute("xmlns:xsi"); + xmlnsXsiAttribute.setValue("http://www.w3.org/2001/XMLSchema-instance"); + mpdElement.setAttributeNode(xmlnsXsiAttribute); + + final Attr xmlns = document.createAttribute("xmlns"); + xmlns.setValue("urn:mpeg:DASH:schema:MPD:2011"); + mpdElement.setAttributeNode(xmlns); + + final Attr xsiSchemaLocationAttribute = document.createAttribute("xsi:schemaLocation"); + xsiSchemaLocationAttribute.setValue("urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd"); + mpdElement.setAttributeNode(xsiSchemaLocationAttribute); + + final Attr minBufferTimeAttribute = document.createAttribute("minBufferTime"); + minBufferTimeAttribute.setValue("PT1.500S"); + mpdElement.setAttributeNode(minBufferTimeAttribute); + + final Attr profilesAttribute = document.createAttribute("profiles"); + profilesAttribute.setValue("urn:mpeg:dash:profile:full:2011"); + mpdElement.setAttributeNode(profilesAttribute); + + final Attr typeAttribute = document.createAttribute("type"); + typeAttribute.setValue("static"); + mpdElement.setAttributeNode(typeAttribute); + + final Attr mediaPresentationDurationAttribute = document.createAttribute( + "mediaPresentationDuration"); + final long streamDuration; + if (deliveryType == DeliveryType.LIVE) { + streamDuration = Integer.parseInt(segmentDuration[0]); + } else if (deliveryType == DeliveryType.OTF) { + streamDuration = getStreamDuration(segmentDuration); + } else { + final long itagItemDuration = itagItem.getApproxDurationMs(); + if (itagItemDuration != -1) { + streamDuration = itagItemDuration; + } else { + if (durationSecondsFallback > 0) { + streamDuration = durationSecondsFallback * 1000; + } else { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the MPD element of the DASH manifest to the document: " + + "the duration of the stream could not be determined and the durationSecondsFallback is less than or equal to 0"); + } + } + } + final double duration = streamDuration / 1000.0; + final String durationSeconds = String.format(Locale.ENGLISH, "%.3f", duration); + mediaPresentationDurationAttribute.setValue("PT" + durationSeconds + "S"); + mpdElement.setAttributeNode(mediaPresentationDurationAttribute); + } catch (final Exception e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the MPD element of the DASH manifest to the document", e); + } + + return document; + } + + /** + * Generate the {@code } element, appended as a child of the {@code } element. + * + *

+ * The {@code } element needs to be generated before this element with + * {@link #generateDocumentAndMpdElement(String[], DeliveryType, ItagItem, long)}. + *

+ * + * @param document the {@link Document} on which the the {@code } element will be + * appended + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } element to the + * document + */ + private static void generatePeriodElement(@Nonnull final Document document) + throws YoutubeDashManifestCreationException { + try { + final Element mpdElement = (Element) document.getElementsByTagName("MPD").item(0); + final Element periodElement = document.createElement("Period"); + mpdElement.appendChild(periodElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the Period element of the DASH manifest to the document", e); + } + } + + /** + * Generate the {@code } element, appended as a child of the {@code } element. + * + *

+ * The {@code } element needs to be generated before this element with + * {@link #generateDocumentAndMpdElement(String[], DeliveryType, ItagItem, long)}. + *

+ * + * @param document the {@link Document} on which the the {@code } element will be + * appended + * @param itagItem the {@link ItagItem} corresponding to the stream, which cannot be null + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } element to the + * document + */ + private static void generateAdaptationSetElement(@Nonnull final Document document, + @Nonnull final ItagItem itagItem) + throws YoutubeDashManifestCreationException { + try { + final Element periodElement = (Element) document.getElementsByTagName("Period").item(0); + final Element adaptationSetElement = document.createElement("AdaptationSet"); + + final Attr idAttribute = document.createAttribute("id"); + idAttribute.setValue("0"); + adaptationSetElement.setAttributeNode(idAttribute); + + final MediaFormat mediaFormat = itagItem.getMediaFormat(); + if (mediaFormat == null || isNullOrEmpty(mediaFormat.mimeType)) { + throw new YoutubeDashManifestCreationException( + "Could not generate the AdaptationSet element of the DASH manifest to the document: the MediaFormat or the mime type of the MediaFormat of the ItagItem is null or empty"); + } + + final Attr mimeTypeAttribute = document.createAttribute("mimeType"); + mimeTypeAttribute.setValue(mediaFormat.mimeType); + adaptationSetElement.setAttributeNode(mimeTypeAttribute); + + final Attr subsegmentAlignmentAttribute = document.createAttribute("subsegmentAlignment"); + subsegmentAlignmentAttribute.setValue("true"); + adaptationSetElement.setAttributeNode(subsegmentAlignmentAttribute); + + periodElement.appendChild(adaptationSetElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the AdaptationSet element of the DASH manifest to the document", e); + } + } + + /** + * Generate the {@code } element, appended as a child of the {@code } + * element. + * + *

+ * This element, with its attributes and values, is: + *

+ *

+ * {@code } + *

+ *

+ * The {@code } element needs to be generated before this element with + * {@link #generateAdaptationSetElement(Document, ItagItem)}). + *

+ * + * @param document the {@link Document} on which the the {@code } element will be + * appended + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } element to the document + */ + private static void generateRoleElement(@Nonnull final Document document) + throws YoutubeDashManifestCreationException { + try { + final Element adaptationSetElement = (Element) document.getElementsByTagName( + "AdaptationSet").item(0); + final Element roleElement = document.createElement("Role"); + + final Attr schemeIdUriAttribute = document.createAttribute("schemeIdUri"); + schemeIdUriAttribute.setValue("urn:mpeg:DASH:role:2011"); + roleElement.setAttributeNode(schemeIdUriAttribute); + + final Attr valueAttribute = document.createAttribute("value"); + valueAttribute.setValue("main"); + roleElement.setAttributeNode(valueAttribute); + + adaptationSetElement.appendChild(roleElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the Role element of the DASH manifest to the document", e); + } + } + + /** + * Generate the {@code } element, appended as a child of the + * {@code } element. + * + *

+ * The {@code } element needs to be generated before this element with + * {@link #generateAdaptationSetElement(Document, ItagItem)}). + *

+ * + * @param document the {@link Document} on which the the {@code } element will + * be appended + * @param itagItem the {@link ItagItem} to use, which cannot be null + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } element + * to the document + */ + private static void generateRepresentationElement(@Nonnull final Document document, + @Nonnull final ItagItem itagItem) + throws YoutubeDashManifestCreationException { + try { + final Element adaptationSetElement = (Element) document.getElementsByTagName( + "AdaptationSet").item(0); + final Element representationElement = document.createElement("Representation"); + + final int id = itagItem.id; + if (id <= 0) { + throw new YoutubeDashManifestCreationException( + "Could not generate the Representation element of the DASH manifest to the document: the id of the ItagItem is less than or equal to 0"); + } + final Attr idAttribute = document.createAttribute("id"); + idAttribute.setValue(String.valueOf(id)); + representationElement.setAttributeNode(idAttribute); + + final String codec = itagItem.getCodec(); + if (isNullOrEmpty(codec)) { + throw new YoutubeDashManifestCreationException( + "Could not generate the AdaptationSet element of the DASH manifest to the document: the codecs value is null or empty"); + } + final Attr codecsAttribute = document.createAttribute("codecs"); + codecsAttribute.setValue(codec); + representationElement.setAttributeNode(codecsAttribute); + + final Attr startWithSAPAttribute = document.createAttribute("startWithSAP"); + startWithSAPAttribute.setValue("1"); + representationElement.setAttributeNode(startWithSAPAttribute); + + final Attr maxPlayoutRateAttribute = document.createAttribute("maxPlayoutRate"); + maxPlayoutRateAttribute.setValue("1"); + representationElement.setAttributeNode(maxPlayoutRateAttribute); + + final int bitrate = itagItem.getBitrate(); + if (bitrate <= 0) { + throw new YoutubeDashManifestCreationException( + "Could not generate the Representation element of the DASH manifest to the document: the bitrate of the ItagItem is less than or equal to 0"); + } + final Attr bandwidthAttribute = document.createAttribute("bandwidth"); + bandwidthAttribute.setValue(String.valueOf(bitrate)); + representationElement.setAttributeNode(bandwidthAttribute); + + final ItagItem.ItagType itagType = itagItem.itagType; + + if (itagType == ItagItem.ItagType.VIDEO || itagType == ItagItem.ItagType.VIDEO_ONLY) { + final int height = itagItem.getHeight(); + final int width = itagItem.getWidth(); + if (height <= 0 && width <= 0) { + throw new YoutubeDashManifestCreationException( + "Could not generate the Representation element of the DASH manifest to the document: the width and the height of the ItagItem are less than or equal to 0"); + } + + if (width > 0) { + final Attr widthAttribute = document.createAttribute("width"); + widthAttribute.setValue(String.valueOf(width)); + representationElement.setAttributeNode(widthAttribute); + } + + final Attr heightAttribute = document.createAttribute("height"); + heightAttribute.setValue(String.valueOf(itagItem.getHeight())); + representationElement.setAttributeNode(heightAttribute); + + final int fps = itagItem.getFps(); + if (fps > 0) { + final Attr frameRateAttribute = document.createAttribute("frameRate"); + frameRateAttribute.setValue(String.valueOf(fps)); + representationElement.setAttributeNode(frameRateAttribute); + } + } + + if (itagType == ItagItem.ItagType.AUDIO && itagItem.getSampleRate() > 0) { + final Attr audioSamplingRateAttribute = document.createAttribute( + "audioSamplingRate"); + audioSamplingRateAttribute.setValue(String.valueOf(itagItem.getSampleRate())); + } + + adaptationSetElement.appendChild(representationElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the Representation element of the DASH manifest to the document", e); + } + } + + /** + * Generate the {@code } element, appended as a child of the + * {@code } element. + * + *

+ * This method is only used when generating DASH manifests of audio streams. + *

+ *

+ * It will produce the following element: + *
+ * {@code + * (where {@code audioChannelsValue} is get from the {@link ItagItem} passed as the second + * parameter of this method) + *

+ *

+ * The {@code } element needs to be generated before this element with + * {@link #generateRepresentationElement(Document, ItagItem)}). + *

+ * + * @param document the {@link Document} on which the {@code } + * element will be appended + * @param itagItem the {@link ItagItem} to use, which cannot be null + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the + * {@code } element + * to the document + */ + private static void generateAudioChannelConfigurationElement( + @Nonnull final Document document, + @Nonnull final ItagItem itagItem) throws YoutubeDashManifestCreationException { + try { + final Element representationElement = (Element) document.getElementsByTagName( + "Representation").item(0); + final Element audioChannelConfigurationElement = document.createElement( + "AudioChannelConfiguration"); + + final Attr schemeIdUriAttribute = document.createAttribute("schemeIdUri"); + schemeIdUriAttribute.setValue( + "urn:mpeg:dash:23003:3:audio_channel_configuration:2011"); + audioChannelConfigurationElement.setAttributeNode(schemeIdUriAttribute); + + final Attr valueAttribute = document.createAttribute("value"); + final int audioChannels = itagItem.getAudioChannels(); + if (audioChannels <= 0) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: the audioChannels value is less than or equal to 0 (" + audioChannels + ")"); + } + valueAttribute.setValue(String.valueOf(itagItem.getAudioChannels())); + audioChannelConfigurationElement.setAttributeNode(valueAttribute); + + representationElement.appendChild(audioChannelConfigurationElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the AudioChannelConfiguration element of the DASH manifest to the document", e); + } + } + + /** + * Generate the {@code } element, appended as a child of the + * {@code } element. + * + *

+ * This method is only used when generating DASH manifests from progressive streams. + *

+ *

+ * The {@code } element needs to be generated before this element with + * {@link #generateRepresentationElement(Document, ItagItem)}). + *

+ * + * @param document the {@link Document} on which the {@code } element will + * be appended + * @param baseUrl the base URL of the stream, which cannot be null and will be set as the + * content of the {@code } element + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } element + * to the document + */ + private static void generateBaseUrlElement(@Nonnull final Document document, + @Nonnull final String baseUrl) + throws YoutubeDashManifestCreationException { + try { + final Element representationElement = (Element) document.getElementsByTagName( + "Representation").item(0); + final Element baseURLElement = document.createElement("BaseURL"); + baseURLElement.setTextContent(baseUrl); + representationElement.appendChild(baseURLElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the BaseURL element of the DASH manifest to the document", e); + } + } + + /** + * Generate the {@code } element, appended as a child of the + * {@code } element. + * + *

+ * This method is only used when generating DASH manifests from progressive streams. + *

+ *

+ * It generates the following element: + *
+ * {@code } + *
+ * (where {@code indexStart} and {@code indexEnd} are gotten from the {@link ItagItem} passed + * as the second parameter) + *

+ *

+ * The {@code } element needs to be generated before this element with + * {@link #generateRepresentationElement(Document, ItagItem)}). + *

+ * + * @param document the {@link Document} on which the {@code } element will + * be appended + * @param itagItem the {@link ItagItem} to use, which cannot be null + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } element + * to the document + */ + private static void generateSegmentBaseElement(@Nonnull final Document document, + @Nonnull final ItagItem itagItem) + throws YoutubeDashManifestCreationException { + try { + final Element representationElement = (Element) document.getElementsByTagName( + "Representation").item(0); + + final Element segmentBaseElement = document.createElement("SegmentBase"); + + final Attr indexRangeAttribute = document.createAttribute("indexRange"); + + final int indexStart = itagItem.getIndexStart(); + if (indexStart < 0) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: the indexStart value of the ItagItem is less than to 0 (" + indexStart + ")"); + } + final int indexEnd = itagItem.getIndexEnd(); + if (indexEnd < 0) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: the indexEnd value of the ItagItem is less than to 0 (" + indexStart + ")"); + } + + indexRangeAttribute.setValue(indexStart + "-" + indexEnd); + segmentBaseElement.setAttributeNode(indexRangeAttribute); + + representationElement.appendChild(segmentBaseElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the SegmentBase element of the DASH manifest to the document", e); + } + } + + /** + * Generate the {@code } element, appended as a child of the + * {@code } element. + * + *

+ * This method is only used when generating DASH manifests from progressive streams. + *

+ *

+ * It generates the following element: + *
+ * {@code } + *
+ * (where {@code indexStart} and {@code indexEnd} are gotten from the {@link ItagItem} passed + * as the second parameter) + *

+ *

+ * The {@code } element needs to be generated before this element with + * {@link #generateSegmentBaseElement(Document, ItagItem)}). + *

+ * + * @param document the {@link Document} on which the {@code } element will + * be appended + * @param itagItem the {@link ItagItem} to use, which cannot be null + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } element + * to the document + */ + private static void generateInitializationElement(@Nonnull final Document document, + @Nonnull final ItagItem itagItem) + throws YoutubeDashManifestCreationException { + try { + final Element segmentBaseElement = (Element) document.getElementsByTagName( + "SegmentBase").item(0); + + final Element initializationElement = document.createElement("Initialization"); + + final Attr rangeAttribute = document.createAttribute("range"); + + final int initStart = itagItem.getInitStart(); + if (initStart < 0) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: the initStart value of the ItagItem is less than to 0 (" + initStart + ")"); + } + final int initEnd = itagItem.getInitEnd(); + if (initEnd < 0) { + throw new YoutubeDashManifestCreationException( + "Could not generate the DASH manifest: the initEnd value of the ItagItem is less than to 0 (" + initEnd + ")"); + } + + rangeAttribute.setValue(initStart + "-" + initEnd); + initializationElement.setAttributeNode(rangeAttribute); + + segmentBaseElement.appendChild(initializationElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the Initialization element of the DASH manifest to the document", e); + } + } + + /** + * Generate the {@code } element, appended as a child of the + * {@code } element. + * + *

+ * This method is only used when generating DASH manifests from OTF and post-live-DVR streams. + *

+ *

+ * It will produce a {@code } element with the following attributes: + *

    + *
  • {@code startNumber}, which takes the value {@code 0} for post-live-DVR streams and + * {@code 1} for OTF streams;
  • + *
  • {@code timescale}, which is always {@code 1000};
  • + *
  • {@code media}, which is the base URL of the stream on which is appended + * {@code &sq=$Number$&rn=$Number$};
  • + *
  • {@code initialization} (only for OTF streams), which is the base URL of the stream + * on which is appended {@link #SQ_0} and {@link #RN_0}.
  • + *
+ *

+ * + *

+ * The {@code } element needs to be generated before this element with + * {@link #generateRepresentationElement(Document, ItagItem)}). + *

+ * + * @param document the {@link Document} on which the {@code } element will + * be appended + * @param baseUrl the base URL of the OTF/post-live-DVR stream + * @param deliveryType the stream {@link DeliveryType delivery type} + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } element + * to the document + */ + private static void generateSegmentTemplateElement(@Nonnull final Document document, + @Nonnull final String baseUrl, + final DeliveryType deliveryType) + throws YoutubeDashManifestCreationException { + try { + final Element representationElement = (Element) document.getElementsByTagName( + "Representation").item(0); + final Element segmentTemplateElement = document.createElement("SegmentTemplate"); + + final Attr startNumberAttribute = document.createAttribute("startNumber"); + final boolean isDeliveryTypeLive = deliveryType == DeliveryType.LIVE; + // The first sequence of post DVR streams is the beginning of the video stream and not + // an initialization segment + final String startNumberValue = isDeliveryTypeLive ? "0" : "1"; + startNumberAttribute.setValue(startNumberValue); + segmentTemplateElement.setAttributeNode(startNumberAttribute); + + final Attr timescaleAttribute = document.createAttribute("timescale"); + timescaleAttribute.setValue("1000"); + segmentTemplateElement.setAttributeNode(timescaleAttribute); + + // Post-live-DVR/ended livestreams streams don't require an initialization sequence + if (!isDeliveryTypeLive) { + final Attr initializationAttribute = document.createAttribute("initialization"); + initializationAttribute.setValue(baseUrl + SQ_0 + RN_0); + segmentTemplateElement.setAttributeNode(initializationAttribute); + } + + final Attr mediaAttribute = document.createAttribute("media"); + mediaAttribute.setValue(baseUrl + "&sq=$Number$&rn=$Number$"); + segmentTemplateElement.setAttributeNode(mediaAttribute); + + representationElement.appendChild(segmentTemplateElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the SegmentTemplate element of the DASH manifest to the document", e); + } + } + + /** + * Generate the {@code } element, appended as a child of the + * {@code } element. + * + *

+ * The {@code } element needs to be generated before this element with + * {@link #generateSegmentTemplateElement(Document, String, DeliveryType)}. + *

+ * + * @param document the {@link Document} on which the the {@code } element will + * be appended + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } element + * to the document + */ + private static void generateSegmentTimelineElement(@Nonnull final Document document) + throws YoutubeDashManifestCreationException { + try { + final Element segmentTemplateElement = (Element) document.getElementsByTagName( + "SegmentTemplate").item(0); + final Element segmentTimelineElement = document.createElement("SegmentTimeline"); + + segmentTemplateElement.appendChild(segmentTimelineElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the SegmentTimeline element of the DASH manifest to the document", e); + } + } + + /** + * Generate segment elements for OTF streams. + * + *

+ * By parsing by the first media sequence, we know how many durations and repetitions there are + * so we just have to loop into {@link #SEGMENTS_DURATION} and {@link #DURATION_REPETITIONS} + * to generate the following element for each duration: + *

+ *

+ * {@code } + *

+ *

+ * If there is no repetition of the duration between two segments, the {@code r} attribute is + * not added to the {@code S} element. + *

+ *

+ * These elements will be appended as children of the {@code } element. + *

+ *

+ * The {@code } element needs to be generated before this element with + * {@link #generateSegmentTimelineElement(Document)}. + *

+ * + * @param document the {@link Document} on which the the {@code } elements will be appended + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } elements to the + * document + */ + private static void generateSegmentElementsForOtfStreams(@Nonnull final Document document) + throws YoutubeDashManifestCreationException { + try { + if (SEGMENTS_DURATION.isEmpty() || DURATION_REPETITIONS.isEmpty()) { + throw new IllegalStateException( + "Duration of segments and/or repetition(s) of segments are unknown"); + } + final Element segmentTimelineElement = (Element) document.getElementsByTagName( + "SegmentTimeline").item(0); + + for (int i = 0; i < SEGMENTS_DURATION.size(); i++) { + final Element sElement = document.createElement("S"); + + final int durationRepetition = DURATION_REPETITIONS.get(i); + if (durationRepetition != 0) { + final Attr rAttribute = document.createAttribute("r"); + rAttribute.setValue(String.valueOf(durationRepetition)); + sElement.setAttributeNode(rAttribute); + } + + final Attr dAttribute = document.createAttribute("d"); + dAttribute.setValue(String.valueOf(SEGMENTS_DURATION.get(i))); + sElement.setAttributeNode(dAttribute); + + segmentTimelineElement.appendChild(sElement); + } + + } catch (final DOMException | IllegalStateException | IndexOutOfBoundsException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the segment (S) elements of the DASH manifest to the document", e); + } + } + + /** + * Generate the segment element for post-live-DVR streams. + * + *

+ * We don't know the exact duration of segments for post-live-DVR streams but an + * average instead (which is the {@code targetDurationSec} value), so we can use the following + * structure to generate the segment timeline for DASH manifests of ended livestreams: + *
+ * {@code } + *

+ * + * @param document the {@link Document} on which the the {@code } element will + * be appended + * @param targetDurationSeconds the {@code targetDurationSec} value from player response's + * stream + * @param segmentCount the number of segments, extracted by the main method which + * generates manifests of post DVR livestreams + * @throws YoutubeDashManifestCreationException if something goes wrong when generating or + * appending the {@code } element to the + * document + */ + private static void generateSegmentElementForPostLiveDvrStreams( + @Nonnull final Document document, + final int targetDurationSeconds, + @Nonnull final String segmentCount) throws YoutubeDashManifestCreationException { + try { + final Element segmentTimelineElement = (Element) document.getElementsByTagName( + "SegmentTimeline").item(0); + final Element sElement = document.createElement("S"); + + final Attr dAttribute = document.createAttribute("d"); + dAttribute.setValue(String.valueOf(targetDurationSeconds * 1000)); + sElement.setAttributeNode(dAttribute); + + final Attr rAttribute = document.createAttribute("r"); + rAttribute.setValue(segmentCount); + sElement.setAttributeNode(rAttribute); + + segmentTimelineElement.appendChild(sElement); + } catch (final DOMException e) { + throw new YoutubeDashManifestCreationException( + "Could not generate or append the segment (S) elements of the DASH manifest to the document", e); + } + } + + /** + * Convert a DASH manifest {@link Document document} to a string. + * + * @param originalBaseStreamingUrl the original base URL of the stream + * @param document the document to be converted + * @param manifestCreatorCache the {@link ManifestCreatorCache} on which store the + * string generated (which is either + * {@link #GENERATED_OTF_MANIFESTS}, + * {@link #GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS} or + * {@link #GENERATED_PROGRESSIVE_STREAMS_MANIFESTS}) + * @return the DASH manifest {@link Document document} converted to a string + * @throws YoutubeDashManifestCreationException if something goes wrong when converting the + * {@link Document document} + */ + private static String buildResult( + @Nonnull final String originalBaseStreamingUrl, + @Nonnull final Document document, + @Nonnull final ManifestCreatorCache manifestCreatorCache) + throws YoutubeDashManifestCreationException { + try { + final StringWriter result = new StringWriter(); + final TransformerFactory transformerFactory = TransformerFactory.newInstance(); + + final Transformer transformer = transformerFactory.newTransformer(); + transformer.setOutputProperty(OutputKeys.VERSION, "1.0"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty(OutputKeys.STANDALONE, "no"); + transformer.transform(new DOMSource(document), new StreamResult(result)); + final String stringResult = result.toString(); + manifestCreatorCache.put(originalBaseStreamingUrl, stringResult); + return stringResult; + } catch (final Exception e) { + throw new YoutubeDashManifestCreationException( + "Could not convert the DASH manifest generated to a string", e); + } + } + + /** + * Get the number of cached OTF streams manifests. + * + * @return the number of cached OTF streams manifests + */ + public static int getOtfCachedManifestsSize() { + return GENERATED_OTF_MANIFESTS.size(); + } + + /** + * Get the number of cached post-live-DVR streams manifests. + * + * @return the number of cached post-live-DVR streams manifests + */ + public static int getPostLiveDvrStreamsCachedManifestsSize() { + return GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.size(); + } + + /** + * Get the number of cached progressive manifests. + * + * @return the number of cached progressive manifests + */ + public static int getProgressiveStreamsCachedManifestsSize() { + return GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.size(); + } + + /** + * Get the number of cached OTF, post-live-DVR streams and progressive manifests. + * + * @return the number of cached OTF, post-live-DVR streams and progressive manifests. + */ + public static int getSizeOfManifestsCaches() { + return GENERATED_OTF_MANIFESTS.size() + + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.size() + + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.size(); + } + + /** + * Get the clear factor of OTF streams manifests cache. + * + * @return the clear factor of OTF streams manifests cache. + */ + public static double getOtfStreamsClearFactor() { + return GENERATED_OTF_MANIFESTS.getClearFactor(); + } + + /** + * Get the clear factor of post-live-DVR streams manifests cache. + * + * @return the clear factor of post-live-DVR streams manifests cache. + */ + public static double getPostLiveDvrStreamsClearFactor() { + return GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.getClearFactor(); + } + + /** + * Get the clear factor of progressive streams manifests cache. + * + * @return the clear factor of progressive streams manifests cache. + */ + public static double getProgressiveStreamsClearFactor() { + return GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.getClearFactor(); + } + + /** + * Set the clear factor of cached OTF streams + * + * @param otfStreamsClearFactor the clear factor of OTF streams manifests cache. + */ + public static void setOtfStreamsClearFactor(final double otfStreamsClearFactor) { + GENERATED_OTF_MANIFESTS.setClearFactor(otfStreamsClearFactor); + } + + /** + * Set the clear factor of cached post-live-DVR streams + * + * @param postLiveDvrStreamsClearFactor the clear factor of post-live-DVR streams manifests + * cache. + */ + public static void setPostLiveDvrStreamsClearFactor( + final double postLiveDvrStreamsClearFactor) { + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.setClearFactor(postLiveDvrStreamsClearFactor); + } + + /** + * Set the clear factor of cached progressive streams + * + * @param progressiveStreamsClearFactor the clear factor of progressive streams manifests + * cache. + */ + public static void setProgressiveStreamsClearFactor( + final double progressiveStreamsClearFactor) { + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.setClearFactor(progressiveStreamsClearFactor); + } + + /** + * Set the clear factor of cached OTF, post-live-DVR and progressive streams. + * + * @param cachesClearFactor the clear factor of OTF, post-live-DVR and progressive streams + * manifests caches. + */ + public static void setCachesClearFactor(final double cachesClearFactor) { + GENERATED_OTF_MANIFESTS.setClearFactor(cachesClearFactor); + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.setClearFactor(cachesClearFactor); + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.setClearFactor(cachesClearFactor); + } + + /** + * Reset the clear factor of OTF streams cache to its + * {@link ManifestCreatorCache#DEFAULT_CLEAR_FACTOR default value}. + */ + public static void resetOtfStreamsClearFactor() { + GENERATED_OTF_MANIFESTS.resetClearFactor(); + } + + /** + * Reset the clear factor of post-live-DVR streams cache to its + * {@link ManifestCreatorCache#DEFAULT_CLEAR_FACTOR default value}. + */ + public static void resetPostLiveDvrStreamsClearFactor() { + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.resetClearFactor(); + } + + /** + * Reset the clear factor of progressive streams cache to its + * {@link ManifestCreatorCache#DEFAULT_CLEAR_FACTOR default value}. + */ + public static void resetProgressiveStreamsClearFactor() { + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.resetClearFactor(); + } + + /** + * Reset the clear factor of OTF, post-live-DVR and progressive streams caches to their + * {@link ManifestCreatorCache#DEFAULT_CLEAR_FACTOR default value}. + */ + public static void resetCachesClearFactor() { + GENERATED_OTF_MANIFESTS.resetClearFactor(); + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.resetClearFactor(); + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.resetClearFactor(); + } + + /** + * Set the limit of cached OTF streams. + * + *

+ * When the cache limit size is reached, oldest manifests will be removed. + *

+ * + *

+ * If the new cache size set is less than the number of current cached manifests, oldest + * manifests will be also removed. + *

+ * + *

+ * Note that the limit must be more than 0 or an {@link IllegalArgumentException} will be + * thrown. + *

+ * + * @param otfStreamsCacheLimit the maximum number of OTF streams in the corresponding cache. + */ + public static void setOtfStreamsMaximumSize(final int otfStreamsCacheLimit) { + GENERATED_OTF_MANIFESTS.setMaximumSize(otfStreamsCacheLimit); + } + + /** + * Set the limit of cached post-live-DVR streams. + * + *

+ * When the cache limit size is reached, oldest manifests will be removed. + *

+ * + *

+ * If the new cache size set is less than the number of current cached manifests, oldest + * manifests will be also removed. + *

+ * + *

+ * Note that the limit must be more than 0 or an {@link IllegalArgumentException} will be + * thrown. + *

+ * + * @param postLiveDvrStreamsCacheLimit the maximum number of post-live-DVR streams in the + * corresponding cache. + */ + public static void setPostLiveDvrStreamsMaximumSize(final int postLiveDvrStreamsCacheLimit) { + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.setMaximumSize(postLiveDvrStreamsCacheLimit); + } + + /** + * Set the limit of cached progressive streams, if needed. + * + *

+ * When the cache limit size is reached, oldest manifests will be removed. + *

+ * + *

+ * If the new cache size set is less than the number of current cached manifests, oldest + * manifests will be also removed. + *

+ * + *

+ * Note that the limit must be more than 0 or an {@link IllegalArgumentException} will be + * thrown. + *

+ * + * @param progressiveCacheLimit the maximum number of progressive streams in the corresponding + * cache. + */ + public static void setProgressiveStreamsMaximumSize(final int progressiveCacheLimit) { + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.setMaximumSize(progressiveCacheLimit); + } + + /** + * Set the limit of cached OTF manifests, cached post-live-DVR manifests and cached progressive + * manifests. + * + *

+ * When the caches limit size are reached, oldest manifests will be removed from their + * respective cache. + *

+ * + *

+ * For each cache, if its new size set is less than the number of current cached manifests, + * oldest manifests will be also removed. + *

+ * + *

+ * Note that the limit must be more than 0 or an {@link IllegalArgumentException} will be + * thrown. + *

+ * + * @param cachesLimit the maximum size of OTF, post-live-DVR and progressive caches + */ + public static void setManifestsCachesMaximumSize(final int cachesLimit) { + GENERATED_OTF_MANIFESTS.setMaximumSize(cachesLimit); + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.setMaximumSize(cachesLimit); + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.setMaximumSize(cachesLimit); + } + + /** + * Clear cached OTF manifests. + * + *

+ * The limit of this cache size set, if there is one, will be not unset. + *

+ */ + public static void clearOtfCachedManifests() { + GENERATED_OTF_MANIFESTS.clear(); + } + + /** + * Clear cached post-live-DVR streams manifests. + * + *

+ * The limit of this cache size set, if there is one, will be not unset. + *

+ */ + public static void clearPostLiveDvrStreamsCachedManifests() { + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.clear(); + } + + /** + * Clear cached progressive streams manifests. + * + *

+ * The limit of this cache size set, if there is one, will be not unset. + *

+ */ + public static void clearProgressiveCachedManifests() { + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.clear(); + } + + /** + * Clear cached OTF manifests, cached post-live-DVR streams manifests and cached progressive + * manifests in their respective caches. + * + *

+ * The limit of the caches size set, if any, will be not unset. + *

+ */ + public static void clearManifestsInCaches() { + GENERATED_OTF_MANIFESTS.clear(); + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.clear(); + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.clear(); + } + + /** + * Reset OTF manifests cache. + * + *

+ * All cached manifests will be removed and the clear factor and the maximum size will be set + * to their default values. + *

+ */ + public static void resetOtfManifestsCache() { + GENERATED_OTF_MANIFESTS.reset(); + } + + /** + * Reset post-live-DVR manifests cache. + * + *

+ * All cached manifests will be removed and the clear factor and the maximum size will be set + * to their default values. + *

+ */ + public static void resetPostLiveDvrManifestsCache() { + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.reset(); + } + + /** + * Reset progressive manifests cache. + * + *

+ * All cached manifests will be removed and the clear factor and the maximum size will be set + * to their default values. + *

+ */ + public static void resetProgressiveManifestsCache() { + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.reset(); + } + + /** + * Reset OTF, post-live-DVR and progressive manifests caches. + * + *

+ * For each cache, all cached manifests will be removed and the clear factor and the maximum + * size will be set to their default values. + *

+ */ + public static void resetCaches() { + GENERATED_OTF_MANIFESTS.reset(); + GENERATED_POST_LIVE_DVR_STREAMS_MANIFESTS.reset(); + GENERATED_PROGRESSIVE_STREAMS_MANIFESTS.reset(); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ManifestCreatorCache.java b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ManifestCreatorCache.java new file mode 100644 index 000000000..8e885f7cf --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/ManifestCreatorCache.java @@ -0,0 +1,301 @@ +package org.schabi.newpipe.extractor.utils; + +import javax.annotation.Nullable; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A {@link Serializable serializable} cache class used by the extractor to cache manifests + * generated with extractor's manifests generators. + * + *

+ * It relies internally on a {@link ConcurrentHashMap} to allow concurrent access to the cache. + *

+ * + * @param the type of cache keys, which must be {@link Serializable serializable} + * @param the type of the second element of {@link Pair pairs} used as values of the cache, + * which must be {@link Serializable serializable} + */ +public final class ManifestCreatorCache + implements Serializable { + + /** + * The default maximum size of a manifest cache. + */ + public static final int DEFAULT_MAXIMUM_SIZE = Integer.MAX_VALUE; + + /** + * The default clear factor of a manifest cache. + */ + public static final double DEFAULT_CLEAR_FACTOR = 0.75; + + /** + * The {@link ConcurrentHashMap} used internally as the cache of manifests. + */ + private final ConcurrentHashMap> concurrentHashMap; + + /** + * The maximum size of the cache. + * + *

+ * The default value is {@link #DEFAULT_MAXIMUM_SIZE}. + *

+ */ + private int maximumSize = DEFAULT_MAXIMUM_SIZE; + + /** + * The clear factor of the cache, which is a double between {@code 0} and {@code 1} excluded. + * + *

+ * The default value is {@link #DEFAULT_CLEAR_FACTOR}. + *

+ */ + private double clearFactor = DEFAULT_CLEAR_FACTOR; + + /** + * Creates a new {@link ManifestCreatorCache}. + */ + public ManifestCreatorCache() { + concurrentHashMap = new ConcurrentHashMap<>(); + } + + /** + * Tests if the specified key is in the cache. + * + * @param key the key to test its presence in the cache + * @return {@code true} if the key is in the cache, {@code false} otherwise. + */ + public boolean containsKey(final K key) { + return concurrentHashMap.containsKey(key); + } + + /** + * Returns the value to which the specified key is mapped, or {@code null} if the cache + * contains no mapping for the key. + * + * @param key the key to which getting its value + * @return the value to which the specified key is mapped, or {@code null} + */ + @Nullable + public Pair get(final K key) { + return concurrentHashMap.get(key); + } + + /** + * Adds a new element to the cache. + * + *

+ * If the cache limit is reached, oldest elements will be cleared first using the load factor + * and the maximum size. + *

+ * + * @param key the key to put + * @param value the value to associate to the key + * + * @return the previous value associated with the key, or {@code null} if there was no mapping + * for the key (note that a null return can also indicate that the cache previously associated + * {@code null} with the key). + */ + @Nullable + public V put(final K key, final V value) { + if (!concurrentHashMap.containsKey(key) && concurrentHashMap.size() == maximumSize) { + final int newCacheSize = (int) Math.round(maximumSize * clearFactor); + keepNewestEntries(newCacheSize != 0 ? newCacheSize : 1); + } + + final Pair returnValue = concurrentHashMap.put(key, + new Pair<>(concurrentHashMap.size(), value)); + return returnValue == null ? null : returnValue.getSecond(); + } + + /** + * Clears the cached manifests. + * + *

+ * The cache will be empty after this method is called. + *

+ */ + public void clear() { + concurrentHashMap.clear(); + } + + /** + * Resets the cache. + * + *

+ * The cache will be empty and the clear factor and the maximum size will be reset to their + * default values. + *

+ * + * @see #clear() + * @see #resetClearFactor() + * @see #resetMaximumSize() + */ + public void reset() { + clear(); + resetClearFactor(); + resetMaximumSize(); + } + + /** + * Returns the number of cached manifests in the cache. + * + * @return the number of cached manifests + */ + public int size() { + return concurrentHashMap.size(); + } + + /** + * Gets the maximum size of the cache. + * + * @return the maximum size of the cache + */ + public long getMaximumSize() { + return maximumSize; + } + + /** + * Sets the maximum size of the cache. + * + * If the current cache size is more than the new maximum size, the percentage of one less the + * clear factor of the maximum new size of manifests in the cache will be removed. + * + * @param maximumSize the new maximum size of the cache + * @throws IllegalArgumentException if {@code maximumSize} is less than or equal to 0 + */ + public void setMaximumSize(final int maximumSize) { + if (maximumSize <= 0) { + throw new IllegalArgumentException("Invalid maximum size"); + } + + if (maximumSize < this.maximumSize && !concurrentHashMap.isEmpty()) { + final int newCacheSize = (int) Math.round(maximumSize * clearFactor); + keepNewestEntries(newCacheSize != 0 ? newCacheSize : 1); + } + + this.maximumSize = maximumSize; + } + + /** + * Resets the maximum size of the cache to its {@link #DEFAULT_MAXIMUM_SIZE default value}. + */ + public void resetMaximumSize() { + this.maximumSize = DEFAULT_MAXIMUM_SIZE; + } + + /** + * Gets the current clear factor of the cache, used when the cache limit size is reached. + * + * @return the current clear factor of the cache + */ + public double getClearFactor() { + return clearFactor; + } + + /** + * Sets the clear factor of the cache, used when the cache limit size is reached. + * + *

+ * The clear factor must be a double between {@code 0} excluded and {@code 1} excluded. + *

+ * + *

+ * Note that it will be only used the next time the cache size limit is reached. + *

+ * + * @param clearFactor the new clear factor of the cache + * @throws IllegalArgumentException if the clear factor passed a parameter is invalid + */ + public void setClearFactor(final double clearFactor) { + if (clearFactor <= 0 || clearFactor >= 1) { + throw new IllegalArgumentException("Invalid clear factor"); + } + + this.clearFactor = clearFactor; + } + + /** + * Resets the clear factor to its {@link #DEFAULT_CLEAR_FACTOR default value}. + */ + public void resetClearFactor() { + this.clearFactor = DEFAULT_CLEAR_FACTOR; + } + + /** + * Reveals whether an object is equal to a {@code ManifestCreator} cache existing object. + * + * @param obj the object to compare with the current {@code ManifestCreatorCache} object + * @return whether the object compared is equal to the current {@code ManifestCreatorCache} + * object + */ + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + final ManifestCreatorCache manifestCreatorCache = + (ManifestCreatorCache) obj; + return maximumSize == manifestCreatorCache.maximumSize + && Double.compare(manifestCreatorCache.clearFactor, clearFactor) == 0 + && concurrentHashMap.equals(manifestCreatorCache.concurrentHashMap); + } + + /** + * Returns a hash code of the current {@code ManifestCreatorCache}, using its + * {@link #maximumSize maximum size}, {@link #clearFactor clear factor} and + * {@link #concurrentHashMap internal concurrent hash map} used as a cache. + * + * @return a hash code of the current {@code ManifestCreatorCache} + */ + @Override + public int hashCode() { + return Objects.hash(maximumSize, clearFactor, concurrentHashMap); + } + + /** + * Returns a string version of the {@link ConcurrentHashMap} used internally as the cache. + * + * @return the string version of the {@link ConcurrentHashMap} used internally as the cache + */ + @Override + public String toString() { + return concurrentHashMap.toString(); + } + + /** + * Keeps only the newest entries in a cache. + * + *

+ * This method will first collect the entries to remove by looping through the concurrent hash + * map + *

+ * + * @param newLimit the new limit of the cache + */ + private void keepNewestEntries(final int newLimit) { + final int difference = concurrentHashMap.size() - newLimit; + final ArrayList>> entriesToRemove = new ArrayList<>(); + + for (final Map.Entry> entry : concurrentHashMap.entrySet()) { + final Pair value = entry.getValue(); + if (value.getFirst() < difference) { + entriesToRemove.add(entry); + } else { + value.setFirst(value.getFirst() - difference); + } + } + + for (final Map.Entry> entry : entriesToRemove) { + concurrentHashMap.remove(entry.getKey(), entry.getValue()); + } + } +}