From 21e579b8868899dd95d730bcb1dc05852536b3ca Mon Sep 17 00:00:00 2001 From: AudricV <74829229+AudricV@users.noreply.github.com> Date: Mon, 18 Nov 2024 04:00:36 +0530 Subject: [PATCH 1/4] [PATCH] [YouTube] Remove age-restricted videos workaround, start poTokens support --- .../services/youtube/PoTokenProvider.java | 41 +++ .../services/youtube/PoTokenResult.java | 22 ++ .../youtube/YoutubeParsingHelper.java | 35 +- .../services/youtube/YoutubeStreamHelper.java | 153 +++++++++ .../extractors/YoutubeStreamExtractor.java | 298 +++++++----------- 5 files changed, 342 insertions(+), 207 deletions(-) create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenResult.java create mode 100644 extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java new file mode 100644 index 0000000000..d3d2a64552 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java @@ -0,0 +1,41 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import javax.annotation.Nullable; + +/** + * An interface to provide poTokens to YouTube player requests. + * + *

+ * On some major clients, YouTube requires that the integrity of the device passes some checks to + * allow playback. + *

+ * + *

+ * These checks involve running codes to verify the integrity and using their result to generate a + * poToken (which likely stands for proof of origin token), using a visitor data ID for logged-out + * users. + *

+ * + *

+ * These tokens may have a role in triggering the sign in requirement. + *

+ */ +public interface PoTokenProvider { + + /** + * Get a {@link PoTokenResult} specific to the desktop website, a.k.a. the WEB InnerTube client. + * + *

+ * To be generated and valid, poTokens from this client must be generated using Google's + * BotGuard machine, which requires a JavaScript engine with a good DOM implementation. They + * must be added to adaptive/DASH streaming URLs with the {@code pot} parameter. + *

+ * + * @return a {@link PoTokenResult} specific to the WEB InnerTube client + */ + @Nullable + PoTokenResult getWebClientPoToken(); + + @Nullable + PoTokenResult getAndroidClientPoToken(); +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenResult.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenResult.java new file mode 100644 index 0000000000..af2520870e --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenResult.java @@ -0,0 +1,22 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import javax.annotation.Nonnull; +import java.util.Objects; + +public final class PoTokenResult { + + /** + * The visitor data associated with a poToken. + */ + public final String visitorData; + + /** + * The poToken, a Protobuf object encoded as a base 64 string. + */ + public final String poToken; + + public PoTokenResult(@Nonnull final String visitorData, @Nonnull final String poToken) { + this.visitorData = Objects.requireNonNull(visitorData); + this.poToken = Objects.requireNonNull(poToken); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java index f514b61d61..deb6c92c04 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java @@ -1200,9 +1200,10 @@ public static JsonBuilder prepareDesktopJsonBuilder( @Nonnull public static JsonBuilder prepareAndroidMobileJsonBuilder( @Nonnull final Localization localization, - @Nonnull final ContentCountry contentCountry) { + @Nonnull final ContentCountry contentCountry, + @Nullable final String visitorData) { // @formatter:off - return JsonObject.builder() + final JsonBuilder builder = JsonObject.builder() .object("context") .object("client") .value("clientName", "ANDROID") @@ -1224,8 +1225,13 @@ public static JsonBuilder prepareAndroidMobileJsonBuilder( .value("androidSdkVersion", 34) .value("hl", localization.getLocalizationCode()) .value("gl", contentCountry.getCountryCode()) - .value("utcOffsetMinutes", 0) - .end() + .value("utcOffsetMinutes", 0); + + if (visitorData != null) { + builder.value("visitorData", visitorData); + } + + builder.end() .object("request") .array("internalExperimentFlags") .end() @@ -1238,6 +1244,7 @@ public static JsonBuilder prepareAndroidMobileJsonBuilder( .end() .end(); // @formatter:on + return builder; } @Nonnull @@ -1308,26 +1315,6 @@ public static JsonBuilder prepareTvHtml5EmbedJsonBuilder( // @formatter:on } - @Nonnull - public static JsonObject getWebPlayerResponse( - @Nonnull final Localization localization, - @Nonnull final ContentCountry contentCountry, - @Nonnull final String videoId) throws IOException, ExtractionException { - final byte[] body = JsonWriter.string( - prepareDesktopJsonBuilder(localization, contentCountry) - .value(VIDEO_ID, videoId) - .value(CONTENT_CHECK_OK, true) - .value(RACY_CHECK_OK, true) - .done()) - .getBytes(StandardCharsets.UTF_8); - final String url = YOUTUBEI_V1_URL + "player" + "?" + DISABLE_PRETTY_PRINT_PARAMETER - + "&$fields=microformat,playabilityStatus,storyboards,videoDetails"; - - return JsonUtils.toJsonObject(getValidJsonResponseBody( - getDownloader().postWithContentTypeJson( - url, getYouTubeHeaders(), body, localization))); - } - @Nonnull public static byte[] createTvHtml5EmbedPlayerBody( @Nonnull final Localization localization, diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java new file mode 100644 index 0000000000..30a7049d37 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java @@ -0,0 +1,153 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonWriter; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.localization.ContentCountry; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.utils.JsonUtils; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.schabi.newpipe.extractor.NewPipe.getDownloader; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.CONTENT_CHECK_OK; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.CPN; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.DISABLE_PRETTY_PRINT_PARAMETER; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.RACY_CHECK_OK; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.VIDEO_ID; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.YOUTUBEI_V1_URL; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.generateTParameter; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonAndroidPostResponse; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonIosPostResponse; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getValidJsonResponseBody; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getYouTubeHeaders; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareAndroidMobileJsonBuilder; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareDesktopJsonBuilder; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareIosMobileJsonBuilder; + +public final class YoutubeStreamHelper { + + private static final String STREAMING_DATA = "streamingData"; + private static final String PLAYER = "player"; + private static final String SERVICE_INTEGRITY_DIMENSIONS = "serviceIntegrityDimensions"; + private static final String PO_TOKEN = "poToken"; + + private YoutubeStreamHelper() { + } + + @Nonnull + public static JsonObject getWebMetadataPlayerResponse( + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId) throws IOException, ExtractionException { + final byte[] body = JsonWriter.string( + prepareDesktopJsonBuilder(localization, contentCountry) + .value(VIDEO_ID, videoId) + .value(CONTENT_CHECK_OK, true) + .value(RACY_CHECK_OK, true) + .done()) + .getBytes(StandardCharsets.UTF_8); + final String url = YOUTUBEI_V1_URL + "player" + "?" + DISABLE_PRETTY_PRINT_PARAMETER + + "&$fields=microformat,playabilityStatus,storyboards,videoDetails"; + + return JsonUtils.toJsonObject(getValidJsonResponseBody( + getDownloader().postWithContentTypeJson( + url, getYouTubeHeaders(), body, localization))); + } + + @Nonnull + public static JsonObject getWebFullPlayerResponse( + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nonnull final PoTokenResult webPoTokenResult) throws IOException, ExtractionException { + final byte[] body = JsonWriter.string( + prepareDesktopJsonBuilder(localization, contentCountry, webPoTokenResult.visitorData) + .value(VIDEO_ID, videoId) + .value(CONTENT_CHECK_OK, true) + .value(RACY_CHECK_OK, true) + .object(SERVICE_INTEGRITY_DIMENSIONS) + .value(PO_TOKEN, webPoTokenResult.poToken) + .end() + .done()) + .getBytes(StandardCharsets.UTF_8); + final String url = YOUTUBEI_V1_URL + "player" + "?" + DISABLE_PRETTY_PRINT_PARAMETER; + + return JsonUtils.toJsonObject(getValidJsonResponseBody( + getDownloader().postWithContentTypeJson( + url, getYouTubeHeaders(), body, localization))); + } + + public static JsonObject getAndroidPlayerResponse(@Nonnull final ContentCountry contentCountry, + @Nonnull final Localization localization, + @Nonnull final String videoId, + @Nonnull final String androidCpn, + @Nonnull final PoTokenResult androidPoTokenResult) + throws IOException, ExtractionException { + final byte[] mobileBody = JsonWriter.string( + prepareAndroidMobileJsonBuilder(localization, contentCountry, androidPoTokenResult.visitorData) + .value(VIDEO_ID, videoId) + .value(CPN, androidCpn) + .value(CONTENT_CHECK_OK, true) + .value(RACY_CHECK_OK, true) + .object(SERVICE_INTEGRITY_DIMENSIONS) + .value(PO_TOKEN, androidPoTokenResult.poToken) + .end() + .done()) + .getBytes(StandardCharsets.UTF_8); + + return getJsonAndroidPostResponse( + "player", + mobileBody, + localization, + "&t=" + generateTParameter() + "&id=" + videoId); + } + + public static JsonObject getAndroidReelPlayerResponse(@Nonnull final ContentCountry contentCountry, + @Nonnull final Localization localization, + @Nonnull final String videoId, + @Nonnull final String androidCpn) + throws IOException, ExtractionException { + final byte[] mobileBody = JsonWriter.string( + prepareAndroidMobileJsonBuilder(localization, contentCountry, null) + .object("playerRequest") + .value(VIDEO_ID, videoId) + .end() + .value("disablePlayerResponse", false) + .value(VIDEO_ID, videoId) + .value(CPN, androidCpn) + .value(CONTENT_CHECK_OK, true) + .value(RACY_CHECK_OK, true) + .done()) + .getBytes(StandardCharsets.UTF_8); + + final JsonObject androidPlayerResponse = getJsonAndroidPostResponse( + "reel/reel_item_watch", + mobileBody, + localization, + "&t=" + generateTParameter() + "&id=" + videoId + "&$fields=playerResponse"); + + return androidPlayerResponse.getObject("playerResponse"); + } + + public static JsonObject getIosPlayerResponse(@Nonnull final ContentCountry contentCountry, + @Nonnull final Localization localization, + @Nonnull final String videoId, + @Nonnull final String iosCpn) + throws IOException, ExtractionException { + final byte[] mobileBody = JsonWriter.string( + prepareIosMobileJsonBuilder(localization, contentCountry) + .value(VIDEO_ID, videoId) + .value(CPN, iosCpn) + .value(CONTENT_CHECK_OK, true) + .value(RACY_CHECK_OK, true) + .done()) + .getBytes(StandardCharsets.UTF_8); + + return getJsonIosPostResponse(PLAYER, + mobileBody, localization, "&t=" + generateTParameter() + + "&id=" + videoId); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java index 988a1bcc23..f2c0a3d114 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java @@ -27,18 +27,12 @@ import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.CPN; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.RACY_CHECK_OK; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.VIDEO_ID; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.createTvHtml5EmbedPlayerBody; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.fixThumbnailUrl; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.generateContentPlaybackNonce; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.generateTParameter; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getImagesFromThumbnailsArray; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonAndroidPostResponse; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonIosPostResponse; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getJsonPostResponse; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTextFromObject; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareAndroidMobileJsonBuilder; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareDesktopJsonBuilder; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.prepareIosMobileJsonBuilder; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import com.grack.nanojson.JsonArray; @@ -51,24 +45,14 @@ import org.schabi.newpipe.extractor.MultiInfoItemsCollector; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.downloader.Downloader; -import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException; -import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException; -import org.schabi.newpipe.extractor.exceptions.PaidContentException; -import org.schabi.newpipe.extractor.exceptions.ParsingException; -import org.schabi.newpipe.extractor.exceptions.PrivateContentException; -import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; +import org.schabi.newpipe.extractor.exceptions.*; import org.schabi.newpipe.extractor.linkhandler.LinkHandler; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.DateWrapper; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.localization.TimeAgoParser; import org.schabi.newpipe.extractor.localization.TimeAgoPatternsManager; -import org.schabi.newpipe.extractor.services.youtube.ItagItem; -import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager; -import org.schabi.newpipe.extractor.services.youtube.YoutubeMetaInfoHelper; -import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; +import org.schabi.newpipe.extractor.services.youtube.*; import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeChannelLinkHandlerFactory; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.DeliveryMethod; @@ -104,6 +88,9 @@ public class YoutubeStreamExtractor extends StreamExtractor { + @Nullable + private static PoTokenProvider poTokenProvider; + private JsonObject playerResponse; private JsonObject nextResponse; @@ -112,7 +99,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { @Nullable private JsonObject androidStreamingData; @Nullable - private JsonObject tvHtml5SimplyEmbedStreamingData; + private JsonObject html5StreamingData; private JsonObject videoPrimaryInfoRenderer; private JsonObject videoSecondaryInfoRenderer; @@ -321,7 +308,7 @@ public long getLength() throws ParsingException { return Long.parseLong(duration); } catch (final Exception e) { return getDurationFromFirstAdaptiveFormat(Arrays.asList( - iosStreamingData, androidStreamingData, tvHtml5SimplyEmbedStreamingData)); + iosStreamingData, androidStreamingData, html5StreamingData)); } } @@ -583,7 +570,7 @@ public String getDashMpdUrl() throws ParsingException { // Android client doesn't contain all available streams (mainly the WEBM ones) return getManifestUrl( "dash", - Arrays.asList(androidStreamingData, tvHtml5SimplyEmbedStreamingData)); + Arrays.asList(androidStreamingData, html5StreamingData)); } @Nonnull @@ -597,7 +584,7 @@ public String getHlsUrl() throws ParsingException { return getManifestUrl( "hls", Arrays.asList( - iosStreamingData, androidStreamingData, tvHtml5SimplyEmbedStreamingData)); + iosStreamingData, androidStreamingData, html5StreamingData)); } @Nonnull @@ -766,7 +753,6 @@ public String getErrorMessage() { private static final String FORMATS = "formats"; private static final String ADAPTIVE_FORMATS = "adaptiveFormats"; private static final String STREAMING_DATA = "streamingData"; - private static final String PLAYER = "player"; private static final String NEXT = "next"; private static final String SIGNATURE_CIPHER = "signatureCipher"; private static final String CIPHER = "cipher"; @@ -779,81 +765,60 @@ public void onFetchPage(@Nonnull final Downloader downloader) final Localization localization = getExtractorLocalization(); final ContentCountry contentCountry = getExtractorContentCountry(); - final JsonObject webPlayerResponse = YoutubeParsingHelper.getWebPlayerResponse( - localization, contentCountry, videoId); + final PoTokenProvider providerInstance = poTokenProvider; + final boolean noPoTokenProviderSet = providerInstance == null; - if (isPlayerResponseNotValid(webPlayerResponse, videoId)) { - // Check the playability status, as private and deleted videos and invalid video IDs do - // not return the ID provided in the player response - // When the requested video is playable and a different video ID is returned, it has - // the OK playability status, meaning the ExtractionException after this check will be - // thrown - checkPlayabilityStatus( - webPlayerResponse, webPlayerResponse.getObject("playabilityStatus")); - throw new ExtractionException("Initial WEB player response is not valid"); - } - - // Save the webPlayerResponse into playerResponse in the case the video cannot be played, - // so some metadata can be retrieved - playerResponse = webPlayerResponse; - - // Use the player response from the player endpoint of the desktop internal API because - // there can be restrictions on videos in the embedded player. - // E.g. if a video is age-restricted, the embedded player's playabilityStatus says that - // the video cannot be played outside of YouTube, but does not show the original message. - final JsonObject playabilityStatus = webPlayerResponse.getObject("playabilityStatus"); - - final boolean isAgeRestricted = "login_required".equalsIgnoreCase( - playabilityStatus.getString("status")) - && playabilityStatus.getString("reason", "") - .contains("age"); + final PoTokenResult webPoTokenResult = noPoTokenProviderSet ? null + : providerInstance.getWebClientPoToken(); + fetchWebClient(localization, contentCountry, videoId, webPoTokenResult); setStreamType(); - if (isAgeRestricted) { - fetchTvHtml5EmbedJsonPlayer(contentCountry, localization, videoId); + // The microformat JSON object of the content is only returned on the WEB client, + // so we need to store it instead of getting it directly from the playerResponse + playerMicroFormatRenderer = playerResponse.getObject("microformat") + .getObject("playerMicroformatRenderer"); - // If no streams can be fetched in the TVHTML5 simply embed client, the video should be - // age-restricted, therefore throw an AgeRestrictedContentException explicitly. - if (tvHtml5SimplyEmbedStreamingData == null) { - throw new AgeRestrictedContentException( - "This age-restricted video cannot be watched."); - } + checkPlayabilityStatus(playerResponse, playerResponse.getObject("playabilityStatus")); - // Refresh the stream type because the stream type may be not properly known for - // age-restricted videos - setStreamType(); - } else { - checkPlayabilityStatus(webPlayerResponse, playabilityStatus); + if (webPoTokenResult == null) { + // TODO: add ability to force fetch iOS player response even if + // webPoTokenResult != null + iosCpn = generateContentPlaybackNonce(); + final JsonObject iosPlayerResponse = YoutubeStreamHelper.getIosPlayerResponse( + contentCountry, localization, videoId, iosCpn); - // Fetching successfully the iOS player is mandatory to get streams - fetchIosMobileJsonPlayer(contentCountry, localization, videoId); + if (isPlayerResponseNotValid(iosPlayerResponse, videoId)) { + throw new ExtractionException("IOS player response is not valid"); + } - try { - fetchAndroidMobileJsonPlayer(contentCountry, localization, videoId); - } catch (final Exception ignored) { - // Ignore exceptions related to ANDROID client fetch or parsing, as it is not - // compulsory to play contents + final JsonObject iosStreamingData = iosPlayerResponse.getObject(STREAMING_DATA); + if (!isNullOrEmpty(iosStreamingData)) { + this.iosStreamingData = iosStreamingData; + // TODO: only assign playerCaptionsTracklistRenderer when iOS client + // fetching is not forced + playerCaptionsTracklistRenderer = iosPlayerResponse.getObject("captions") + .getObject("playerCaptionsTracklistRenderer"); } } - // The microformat JSON object of the content is only returned on the WEB client, - // so we need to store it instead of getting it directly from the playerResponse - playerMicroFormatRenderer = webPlayerResponse.getObject("microformat") - .getObject("playerMicroformatRenderer"); + final PoTokenResult androidPoTokenResult = noPoTokenProviderSet ? null + : providerInstance.getAndroidClientPoToken(); + + fetchAndroidClient(localization, contentCountry, videoId, androidPoTokenResult); - final byte[] body = JsonWriter.string( + final byte[] nextBody = JsonWriter.string( prepareDesktopJsonBuilder(localization, contentCountry) .value(VIDEO_ID, videoId) .value(CONTENT_CHECK_OK, true) .value(RACY_CHECK_OK, true) .done()) .getBytes(StandardCharsets.UTF_8); - nextResponse = getJsonPostResponse(NEXT, body, localization); + nextResponse = getJsonPostResponse(NEXT, nextBody, localization); } - private void checkPlayabilityStatus(final JsonObject youtubePlayerResponse, - @Nonnull final JsonObject playabilityStatus) + private static void checkPlayabilityStatus(final JsonObject youtubePlayerResponse, + @Nonnull final JsonObject playabilityStatus) throws ParsingException { String status = playabilityStatus.getString("status"); if (status == null || status.equalsIgnoreCase("ok")) { @@ -867,10 +832,14 @@ private void checkPlayabilityStatus(final JsonObject youtubePlayerResponse, status = newPlayabilityStatus.getString("status"); final String reason = newPlayabilityStatus.getString("reason"); - if (status.equalsIgnoreCase("login_required") && reason == null) { - final String message = newPlayabilityStatus.getArray("messages").getString(0); - if (message != null && message.contains("private")) { - throw new PrivateContentException("This video is private."); + if (status.equalsIgnoreCase("login_required")) { + if (reason == null) { + final String message = newPlayabilityStatus.getArray("messages").getString(0); + if (message != null && message.contains("private")) { + throw new PrivateContentException("This video is private."); + } + } else if (reason.contains("age")) { + throw new AgeRestrictedContentException("Age-restricted videos cannot be watched anonymously"); } } @@ -905,115 +874,73 @@ private void checkPlayabilityStatus(final JsonObject youtubePlayerResponse, throw new ContentNotAvailableException("Got error: \"" + reason + "\""); } - /** - * Fetch the Android Mobile API and assign the streaming data to the androidStreamingData JSON - * object. - */ - private void fetchAndroidMobileJsonPlayer(@Nonnull final ContentCountry contentCountry, - @Nonnull final Localization localization, - @Nonnull final String videoId) - throws IOException, ExtractionException { - androidCpn = generateContentPlaybackNonce(); - final byte[] mobileBody = JsonWriter.string( - prepareAndroidMobileJsonBuilder(localization, contentCountry) - .object("playerRequest") - .value(VIDEO_ID, videoId) - .end() - .value("disablePlayerResponse", false) - .value(VIDEO_ID, videoId) - .value(CPN, androidCpn) - .value(CONTENT_CHECK_OK, true) - .value(RACY_CHECK_OK, true) - .done()) - .getBytes(StandardCharsets.UTF_8); - - final JsonObject androidPlayerResponse = getJsonAndroidPostResponse( - "reel/reel_item_watch", - mobileBody, - localization, - "&t=" + generateTParameter() + "&id=" + videoId + "&$fields=playerResponse"); - - final JsonObject playerResponseObject = androidPlayerResponse.getObject("playerResponse"); - if (isPlayerResponseNotValid(playerResponseObject, videoId)) { - return; - } - - final JsonObject streamingData = playerResponseObject.getObject(STREAMING_DATA); - if (!isNullOrEmpty(streamingData)) { - androidStreamingData = streamingData; - if (isNullOrEmpty(playerCaptionsTracklistRenderer)) { - playerCaptionsTracklistRenderer = playerResponseObject.getObject("captions") + private void fetchWebClient(@Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nullable final PoTokenResult webPoTokenResult) throws IOException, ExtractionException { + final JsonObject webPlayerResponse; + if (webPoTokenResult == null) { + webPlayerResponse = YoutubeStreamHelper.getWebMetadataPlayerResponse( + localization, contentCountry, videoId); + } else { + webPlayerResponse = YoutubeStreamHelper.getWebFullPlayerResponse( + localization, contentCountry, videoId, webPoTokenResult); + final JsonObject webStreamingData = webPlayerResponse.getObject(STREAMING_DATA); + if (!isNullOrEmpty(webStreamingData)) { + html5StreamingData = webStreamingData; + playerCaptionsTracklistRenderer = webPlayerResponse.getObject("captions") .getObject("playerCaptionsTracklistRenderer"); } } - } - /** - * Fetch the iOS Mobile API and assign the streaming data to the iosStreamingData JSON - * object. - */ - private void fetchIosMobileJsonPlayer(@Nonnull final ContentCountry contentCountry, - @Nonnull final Localization localization, - @Nonnull final String videoId) - throws IOException, ExtractionException { - iosCpn = generateContentPlaybackNonce(); - final byte[] mobileBody = JsonWriter.string( - prepareIosMobileJsonBuilder(localization, contentCountry) - .value(VIDEO_ID, videoId) - .value(CPN, iosCpn) - .value(CONTENT_CHECK_OK, true) - .value(RACY_CHECK_OK, true) - .done()) - .getBytes(StandardCharsets.UTF_8); - - final JsonObject iosPlayerResponse = getJsonIosPostResponse(PLAYER, - mobileBody, localization, "&t=" + generateTParameter() - + "&id=" + videoId); - - if (isPlayerResponseNotValid(iosPlayerResponse, videoId)) { - throw new ExtractionException("IOS player response is not valid"); + if (isPlayerResponseNotValid(webPlayerResponse, videoId)) { + // Check the playability status, as private and deleted videos and invalid video IDs do + // not return the ID provided in the player response + // When the requested video is playable and a different video ID is returned, it has + // the OK playability status, meaning the ExtractionException after this check will be + // thrown + checkPlayabilityStatus( + webPlayerResponse, webPlayerResponse.getObject("playabilityStatus")); + throw new ExtractionException("Initial WEB player response is not valid"); } - final JsonObject streamingData = iosPlayerResponse.getObject(STREAMING_DATA); - if (!isNullOrEmpty(streamingData)) { - iosStreamingData = streamingData; - playerCaptionsTracklistRenderer = iosPlayerResponse.getObject("captions") - .getObject("playerCaptionsTracklistRenderer"); - } + // Save the webPlayerResponse into playerResponse in the case the video cannot be played, + // so some metadata can be retrieved + playerResponse = webPlayerResponse; } - /** - * Download the {@code TVHTML5_SIMPLY_EMBEDDED_PLAYER} JSON player as an embed client to bypass - * some age-restrictions and assign the streaming data to the {@code html5StreamingData} JSON - * object. - * - * @param contentCountry the content country to use - * @param localization the localization to use - * @param videoId the video id - */ - private void fetchTvHtml5EmbedJsonPlayer(@Nonnull final ContentCountry contentCountry, - @Nonnull final Localization localization, - @Nonnull final String videoId) - throws IOException, ExtractionException { - tvHtml5SimplyEmbedCpn = generateContentPlaybackNonce(); - - final JsonObject tvHtml5EmbedPlayerResponse = getJsonPostResponse(PLAYER, - createTvHtml5EmbedPlayerBody(localization, - contentCountry, - videoId, - YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId), - tvHtml5SimplyEmbedCpn), localization); - - if (isPlayerResponseNotValid(tvHtml5EmbedPlayerResponse, videoId)) { - throw new ExtractionException("TVHTML5 embed player response is not valid"); - } + private void fetchAndroidClient(@Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nullable final PoTokenResult androidPoTokenResult) { + try { + final JsonObject androidPlayerResponse; + androidCpn = generateContentPlaybackNonce(); + + if (androidPoTokenResult == null) { + androidPlayerResponse = YoutubeStreamHelper.getAndroidReelPlayerResponse( + contentCountry, localization, videoId, androidCpn); + } else { + androidPlayerResponse = YoutubeStreamHelper.getAndroidPlayerResponse( + contentCountry, localization, videoId, androidCpn, + androidPoTokenResult); + } - final JsonObject streamingData = tvHtml5EmbedPlayerResponse.getObject(STREAMING_DATA); - if (!isNullOrEmpty(streamingData)) { - playerResponse = tvHtml5EmbedPlayerResponse; - tvHtml5SimplyEmbedStreamingData = streamingData; - playerCaptionsTracklistRenderer = playerResponse.getObject("captions") - .getObject("playerCaptionsTracklistRenderer"); + if (!isPlayerResponseNotValid(androidPlayerResponse, videoId)) { + final JsonObject androidStreamingData = + androidPlayerResponse.getObject(STREAMING_DATA); + if (!isNullOrEmpty(androidStreamingData)) { + this.androidStreamingData = androidStreamingData; + if (isNullOrEmpty(playerCaptionsTracklistRenderer)) { + playerCaptionsTracklistRenderer = + androidPlayerResponse.getObject("captions") + .getObject("playerCaptionsTracklistRenderer"); + } + } + } + } catch (final Exception ignored) { + // Ignore exceptions related to ANDROID client fetch or parsing, as it is not + // compulsory to play contents } } @@ -1118,7 +1045,7 @@ private List getItags( */ new Pair<>(iosStreamingData, iosCpn), new Pair<>(androidStreamingData, androidCpn), - new Pair<>(tvHtml5SimplyEmbedStreamingData, tvHtml5SimplyEmbedCpn) + new Pair<>(html5StreamingData, tvHtml5SimplyEmbedCpn) ) .flatMap(pair -> getStreamsFromStreamingDataKey(videoId, pair.getFirst(), streamingDataKey, itagTypeWanted, pair.getSecond())) @@ -1587,4 +1514,9 @@ public List getMetaInfo() throws ParsingException { .getObject("results") .getArray("contents")); } + + public static void setPoTokenProvider(@Nullable final PoTokenProvider poTokenProvider) { + // TODO: document the method and handle concurrent calls + YoutubeStreamExtractor.poTokenProvider = poTokenProvider; + } } From f79abbf6f14e95141ac38398a003526731f0110c Mon Sep 17 00:00:00 2001 From: Kavin <20838718+FireMasterK@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:08:38 +0530 Subject: [PATCH 2/4] Fix remaining todos in the code. The PoTokenProvider class is now expected to handle calls from multiple threads for thread safety. --- .../services/youtube/PoTokenProvider.java | 2 + .../extractors/YoutubeStreamExtractor.java | 41 +++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java index d3d2a64552..13323184c9 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java @@ -5,6 +5,8 @@ /** * An interface to provide poTokens to YouTube player requests. * + * @implNote This interface is expected to be thread-safe, as it may be accessed by multiple threads. + * *

* On some major clients, YouTube requires that the integrity of the device passes some checks to * allow playback. diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java index f2c0a3d114..ac60cd10ac 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java @@ -116,6 +116,8 @@ public class YoutubeStreamExtractor extends StreamExtractor { private String androidCpn; private String tvHtml5SimplyEmbedCpn; + private static boolean forceFetchIosClient; + public YoutubeStreamExtractor(final StreamingService service, final LinkHandler linkHandler) { super(service, linkHandler); } @@ -781,9 +783,7 @@ public void onFetchPage(@Nonnull final Downloader downloader) checkPlayabilityStatus(playerResponse, playerResponse.getObject("playabilityStatus")); - if (webPoTokenResult == null) { - // TODO: add ability to force fetch iOS player response even if - // webPoTokenResult != null + if (forceFetchIosClient || webPoTokenResult == null) { iosCpn = generateContentPlaybackNonce(); final JsonObject iosPlayerResponse = YoutubeStreamHelper.getIosPlayerResponse( contentCountry, localization, videoId, iosCpn); @@ -795,10 +795,10 @@ public void onFetchPage(@Nonnull final Downloader downloader) final JsonObject iosStreamingData = iosPlayerResponse.getObject(STREAMING_DATA); if (!isNullOrEmpty(iosStreamingData)) { this.iosStreamingData = iosStreamingData; - // TODO: only assign playerCaptionsTracklistRenderer when iOS client - // fetching is not forced - playerCaptionsTracklistRenderer = iosPlayerResponse.getObject("captions") - .getObject("playerCaptionsTracklistRenderer"); + if (!forceFetchIosClient) { + playerCaptionsTracklistRenderer = iosPlayerResponse.getObject("captions") + .getObject("playerCaptionsTracklistRenderer"); + } } } @@ -1515,8 +1515,33 @@ public List getMetaInfo() throws ParsingException { .getArray("contents")); } + /** + * Sets the {@link PoTokenProvider} instance to be used for fetching poTokens. + * + *

+ * This method allows setting an implementation of {@link PoTokenProvider} which will be used + * to obtain poTokens required for YouTube player requests. These tokens are used by YouTube to verify the + * integrity of the device and may be necessary for playback at times. + *

+ * + * @param poTokenProvider the {@link PoTokenProvider} instance to set + */ public static void setPoTokenProvider(@Nullable final PoTokenProvider poTokenProvider) { - // TODO: document the method and handle concurrent calls YoutubeStreamExtractor.poTokenProvider = poTokenProvider; } + + /** + * Sets whether to force fetch the iOS player response even if the webPoTokenResult is not null. + * + *

+ * This method allows setting a flag to force the fetching of the iOS player response, even if a + * valid webPoTokenResult is available. This can be useful in scenarios where streams from the iOS player + * response is preferred. + *

+ * + * @param forceFetchIosClient a boolean flag indicating whether to force fetch the iOS player response + */ + public static void setForceFetchIosClient(boolean forceFetchIosClient) { + YoutubeStreamExtractor.forceFetchIosClient = forceFetchIosClient; + } } From 6f88ce659f4d2a48c93000481fdfe33de70890a1 Mon Sep 17 00:00:00 2001 From: Kavin <20838718+FireMasterK@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:47:40 +0530 Subject: [PATCH 3/4] Ignore checkstyle for implNote. --- checkstyle/checkstyle.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/checkstyle/checkstyle.xml b/checkstyle/checkstyle.xml index 3b5825a3be..822e0688de 100644 --- a/checkstyle/checkstyle.xml +++ b/checkstyle/checkstyle.xml @@ -192,4 +192,9 @@ + + + + + From fbee94e53f4960d726b858ade5025af260969bf4 Mon Sep 17 00:00:00 2001 From: Kavin <20838718+FireMasterK@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:47:51 +0530 Subject: [PATCH 4/4] Fix all checkstyle issues. --- .../services/youtube/PoTokenProvider.java | 4 +- .../services/youtube/YoutubeStreamHelper.java | 34 ++++++++---- .../extractors/YoutubeStreamExtractor.java | 52 +++++++++++++------ 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java index 13323184c9..3faff9c3fa 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java @@ -5,7 +5,6 @@ /** * An interface to provide poTokens to YouTube player requests. * - * @implNote This interface is expected to be thread-safe, as it may be accessed by multiple threads. * *

* On some major clients, YouTube requires that the integrity of the device passes some checks to @@ -21,6 +20,9 @@ *

* These tokens may have a role in triggering the sign in requirement. *

+ * + * @implNote This interface is expected to be thread-safe, + * as it may be accessed by multiple threads. */ public interface PoTokenProvider { diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java index 30a7049d37..98d17591ce 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java @@ -64,7 +64,11 @@ public static JsonObject getWebFullPlayerResponse( @Nonnull final String videoId, @Nonnull final PoTokenResult webPoTokenResult) throws IOException, ExtractionException { final byte[] body = JsonWriter.string( - prepareDesktopJsonBuilder(localization, contentCountry, webPoTokenResult.visitorData) + prepareDesktopJsonBuilder( + localization, + contentCountry, + webPoTokenResult.visitorData + ) .value(VIDEO_ID, videoId) .value(CONTENT_CHECK_OK, true) .value(RACY_CHECK_OK, true) @@ -80,14 +84,20 @@ public static JsonObject getWebFullPlayerResponse( url, getYouTubeHeaders(), body, localization))); } - public static JsonObject getAndroidPlayerResponse(@Nonnull final ContentCountry contentCountry, - @Nonnull final Localization localization, - @Nonnull final String videoId, - @Nonnull final String androidCpn, - @Nonnull final PoTokenResult androidPoTokenResult) + public static JsonObject getAndroidPlayerResponse( + @Nonnull final ContentCountry contentCountry, + @Nonnull final Localization localization, + @Nonnull final String videoId, + @Nonnull final String androidCpn, + @Nonnull final PoTokenResult androidPoTokenResult + ) throws IOException, ExtractionException { final byte[] mobileBody = JsonWriter.string( - prepareAndroidMobileJsonBuilder(localization, contentCountry, androidPoTokenResult.visitorData) + prepareAndroidMobileJsonBuilder( + localization, + contentCountry, + androidPoTokenResult.visitorData + ) .value(VIDEO_ID, videoId) .value(CPN, androidCpn) .value(CONTENT_CHECK_OK, true) @@ -105,10 +115,12 @@ public static JsonObject getAndroidPlayerResponse(@Nonnull final ContentCountry "&t=" + generateTParameter() + "&id=" + videoId); } - public static JsonObject getAndroidReelPlayerResponse(@Nonnull final ContentCountry contentCountry, - @Nonnull final Localization localization, - @Nonnull final String videoId, - @Nonnull final String androidCpn) + public static JsonObject getAndroidReelPlayerResponse( + @Nonnull final ContentCountry contentCountry, + @Nonnull final Localization localization, + @Nonnull final String videoId, + @Nonnull final String androidCpn + ) throws IOException, ExtractionException { final byte[] mobileBody = JsonWriter.string( prepareAndroidMobileJsonBuilder(localization, contentCountry, null) diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java index ac60cd10ac..68705c7a22 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java @@ -45,14 +45,27 @@ import org.schabi.newpipe.extractor.MultiInfoItemsCollector; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.downloader.Downloader; -import org.schabi.newpipe.extractor.exceptions.*; +import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException; +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException; +import org.schabi.newpipe.extractor.exceptions.PaidContentException; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.exceptions.PrivateContentException; +import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; import org.schabi.newpipe.extractor.linkhandler.LinkHandler; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.DateWrapper; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.localization.TimeAgoParser; import org.schabi.newpipe.extractor.localization.TimeAgoPatternsManager; -import org.schabi.newpipe.extractor.services.youtube.*; +import org.schabi.newpipe.extractor.services.youtube.ItagItem; +import org.schabi.newpipe.extractor.services.youtube.PoTokenProvider; +import org.schabi.newpipe.extractor.services.youtube.PoTokenResult; +import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptPlayerManager; +import org.schabi.newpipe.extractor.services.youtube.YoutubeMetaInfoHelper; +import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; +import org.schabi.newpipe.extractor.services.youtube.YoutubeStreamHelper; import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeChannelLinkHandlerFactory; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.DeliveryMethod; @@ -792,9 +805,9 @@ public void onFetchPage(@Nonnull final Downloader downloader) throw new ExtractionException("IOS player response is not valid"); } - final JsonObject iosStreamingData = iosPlayerResponse.getObject(STREAMING_DATA); - if (!isNullOrEmpty(iosStreamingData)) { - this.iosStreamingData = iosStreamingData; + final JsonObject iosStreamingDataLocal = iosPlayerResponse.getObject(STREAMING_DATA); + if (!isNullOrEmpty(iosStreamingDataLocal)) { + this.iosStreamingData = iosStreamingDataLocal; if (!forceFetchIosClient) { playerCaptionsTracklistRenderer = iosPlayerResponse.getObject("captions") .getObject("playerCaptionsTracklistRenderer"); @@ -839,7 +852,9 @@ private static void checkPlayabilityStatus(final JsonObject youtubePlayerRespons throw new PrivateContentException("This video is private."); } } else if (reason.contains("age")) { - throw new AgeRestrictedContentException("Age-restricted videos cannot be watched anonymously"); + throw new AgeRestrictedContentException( + "Age-restricted videos cannot be watched anonymously" + ); } } @@ -877,7 +892,8 @@ private static void checkPlayabilityStatus(final JsonObject youtubePlayerRespons private void fetchWebClient(@Nonnull final Localization localization, @Nonnull final ContentCountry contentCountry, @Nonnull final String videoId, - @Nullable final PoTokenResult webPoTokenResult) throws IOException, ExtractionException { + @Nullable final PoTokenResult webPoTokenResult + ) throws IOException, ExtractionException { final JsonObject webPlayerResponse; if (webPoTokenResult == null) { webPlayerResponse = YoutubeStreamHelper.getWebMetadataPlayerResponse( @@ -927,10 +943,10 @@ private void fetchAndroidClient(@Nonnull final Localization localization, } if (!isPlayerResponseNotValid(androidPlayerResponse, videoId)) { - final JsonObject androidStreamingData = + final JsonObject androidStreamingDataLocal = androidPlayerResponse.getObject(STREAMING_DATA); - if (!isNullOrEmpty(androidStreamingData)) { - this.androidStreamingData = androidStreamingData; + if (!isNullOrEmpty(androidStreamingDataLocal)) { + this.androidStreamingData = androidStreamingDataLocal; if (isNullOrEmpty(playerCaptionsTracklistRenderer)) { playerCaptionsTracklistRenderer = androidPlayerResponse.getObject("captions") @@ -1519,9 +1535,10 @@ public List getMetaInfo() throws ParsingException { * Sets the {@link PoTokenProvider} instance to be used for fetching poTokens. * *

- * This method allows setting an implementation of {@link PoTokenProvider} which will be used - * to obtain poTokens required for YouTube player requests. These tokens are used by YouTube to verify the - * integrity of the device and may be necessary for playback at times. + * This method allows setting an implementation of {@link PoTokenProvider} which will + * be used to obtain poTokens required for YouTube player requests. These tokens are + * used by YouTube to verify the integrity of the device and may be necessary for + * playback at times. *

* * @param poTokenProvider the {@link PoTokenProvider} instance to set @@ -1535,13 +1552,14 @@ public static void setPoTokenProvider(@Nullable final PoTokenProvider poTokenPro * *

* This method allows setting a flag to force the fetching of the iOS player response, even if a - * valid webPoTokenResult is available. This can be useful in scenarios where streams from the iOS player - * response is preferred. + * valid webPoTokenResult is available. This can be useful in scenarios where streams from the + * iOS player response is preferred. *

* - * @param forceFetchIosClient a boolean flag indicating whether to force fetch the iOS player response + * @param forceFetchIosClient a boolean flag indicating whether to force fetch the iOS + * player response */ - public static void setForceFetchIosClient(boolean forceFetchIosClient) { + public static void setForceFetchIosClient(final boolean forceFetchIosClient) { YoutubeStreamExtractor.forceFetchIosClient = forceFetchIosClient; } }