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 @@
+
+
+
+
+
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..3faff9c3fa
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java
@@ -0,0 +1,45 @@
+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.
+ *
+ *
+ * @implNote This interface is expected to be thread-safe,
+ * as it may be accessed by multiple threads.
+ */
+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..98d17591ce
--- /dev/null
+++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java
@@ -0,0 +1,165 @@
+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..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
@@ -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;
@@ -66,9 +60,12 @@
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.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;
@@ -104,6 +101,9 @@
public class YoutubeStreamExtractor extends StreamExtractor {
+ @Nullable
+ private static PoTokenProvider poTokenProvider;
+
private JsonObject playerResponse;
private JsonObject nextResponse;
@@ -112,7 +112,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
@Nullable
private JsonObject androidStreamingData;
@Nullable
- private JsonObject tvHtml5SimplyEmbedStreamingData;
+ private JsonObject html5StreamingData;
private JsonObject videoPrimaryInfoRenderer;
private JsonObject videoSecondaryInfoRenderer;
@@ -129,6 +129,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);
}
@@ -321,7 +323,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 +585,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 +599,7 @@ public String getHlsUrl() throws ParsingException {
return getManifestUrl(
"hls",
Arrays.asList(
- iosStreamingData, androidStreamingData, tvHtml5SimplyEmbedStreamingData));
+ iosStreamingData, androidStreamingData, html5StreamingData));
}
@Nonnull
@@ -766,7 +768,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 +780,58 @@ 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 (forceFetchIosClient || 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 iosStreamingDataLocal = iosPlayerResponse.getObject(STREAMING_DATA);
+ if (!isNullOrEmpty(iosStreamingDataLocal)) {
+ this.iosStreamingData = iosStreamingDataLocal;
+ if (!forceFetchIosClient) {
+ 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 +845,16 @@ 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 +889,74 @@ 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 androidStreamingDataLocal =
+ androidPlayerResponse.getObject(STREAMING_DATA);
+ if (!isNullOrEmpty(androidStreamingDataLocal)) {
+ this.androidStreamingData = androidStreamingDataLocal;
+ 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 +1061,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 +1530,36 @@ public List getMetaInfo() throws ParsingException {
.getObject("results")
.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) {
+ 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(final boolean forceFetchIosClient) {
+ YoutubeStreamExtractor.forceFetchIosClient = forceFetchIosClient;
+ }
}