diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ClientsConstants.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ClientsConstants.java new file mode 100644 index 000000000..4b5e291ff --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/ClientsConstants.java @@ -0,0 +1,118 @@ +package org.schabi.newpipe.extractor.services.youtube; + +final class ClientsConstants { + private ClientsConstants() { + } + + // Common client fields + + static final String DESKTOP_CLIENT_PLATFORM = "DESKTOP"; + static final String MOBILE_CLIENT_PLATFORM = "MOBILE"; + static final String WATCH_CLIENT_SCREEN = "WATCH"; + static final String EMBED_CLIENT_SCREEN = "EMBED"; + + // WEB (YouTube desktop) client fields + + static final String WEB_CLIENT_ID = "1"; + static final String WEB_CLIENT_NAME = "WEB"; + /** + * The client version for InnerTube requests with the {@code WEB} client, used as the last + * fallback if the extraction of the real one failed. + */ + static final String WEB_HARDCODED_CLIENT_VERSION = "2.20250122.04.00"; + + // WEB_REMIX (YouTube Music) client fields + + static final String WEB_REMIX_CLIENT_ID = "67"; + static final String WEB_REMIX_CLIENT_NAME = "WEB_REMIX"; + static final String WEB_REMIX_HARDCODED_CLIENT_VERSION = "1.20250122.01.00"; + + // TVHTML5 (YouTube on TVs and consoles using HTML5) client fields + static final String TVHTML5_CLIENT_ID = "7"; + static final String TVHTML5_CLIENT_NAME = "TVHTML5"; + static final String TVHTML5_CLIENT_VERSION = "7.20250122.15.00"; + static final String TVHTML5_CLIENT_PLATFORM = "GAME_CONSOLE"; + static final String TVHTML5_DEVICE_MAKE = "Sony"; + static final String TVHTML5_DEVICE_MODEL_AND_OS_NAME = "PlayStation 4"; + // CHECKSTYLE:OFF + static final String TVHTML5_USER_AGENT = + "Mozilla/5.0 (PlayStation; PlayStation 4/12.00) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15"; + // CHECKSTYLE:ON + + // WEB_EMBEDDED_PLAYER (YouTube embeds) + + static final String WEB_EMBEDDED_CLIENT_ID = "56"; + static final String WEB_EMBEDDED_CLIENT_NAME = "WEB_EMBEDDED_PLAYER"; + static final String WEB_EMBEDDED_CLIENT_VERSION = "1.20250121.00.00"; + + // IOS (iOS YouTube app) client fields + + static final String IOS_CLIENT_ID = "5"; + static final String IOS_CLIENT_NAME = "IOS"; + + /** + * The hardcoded client version of the iOS app used for InnerTube requests with this client. + * + *

+ * It can be extracted by getting the latest release version of the app on + * the App + * Store page of the YouTube app, in the {@code What’s New} section. + *

+ */ + static final String IOS_CLIENT_VERSION = "20.03.02"; + + /** + * The device machine id for the iPhone 15 Pro Max, used to get 60fps with the {@code iOS} + * client. + * + *

+ * See this GitHub Gist for more + * information. + *

+ */ + static final String IOS_DEVICE_MODEL = "iPhone16,2"; + + /** + * The iOS version to be used in JSON POST requests, the one of an iPhone 15 Pro Max running + * iOS 18.2.1 with the hardcoded version of the iOS app (for the {@code "osVersion"} field). + * + *

+ * The value of this field seems to use the following structure: + * "iOS major version.minor version.patch version.build version", where + * "patch version" is equal to 0 if it isn't set + * The build version corresponding to the iOS version used can be found on + * + * https://theapplewiki.com/wiki/Firmware/iPhone/18.x#iPhone_15_Pro_Max + *

+ * + * @see #IOS_USER_AGENT_VERSION + */ + static final String IOS_OS_VERSION = "18.2.1.22C161"; + + /** + * The iOS version to be used in the HTTP user agent for requests. + * + *

+ * This should be the same of as {@link #IOS_OS_VERSION}. + *

+ * + * @see #IOS_OS_VERSION + */ + static final String IOS_USER_AGENT_VERSION = "18_2_1"; + + // ANDROID (Android YouTube app) client fields + + static final String ANDROID_CLIENT_ID = "3"; + static final String ANDROID_CLIENT_NAME = "ANDROID"; + + /** + * The hardcoded client version of the Android app used for InnerTube requests with this + * client. + * + *

+ * It can be extracted by getting the latest release version of the app in an APK repository + * such as APKMirror. + *

+ */ + static final String ANDROID_CLIENT_VERSION = "19.28.35"; +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/InnertubeClientRequestInfo.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/InnertubeClientRequestInfo.java new file mode 100644 index 000000000..c8848b086 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/InnertubeClientRequestInfo.java @@ -0,0 +1,148 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_CLIENT_ID; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_CLIENT_NAME; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_CLIENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.DESKTOP_CLIENT_PLATFORM; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.EMBED_CLIENT_SCREEN; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_CLIENT_ID; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_CLIENT_NAME; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_CLIENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_DEVICE_MODEL; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_OS_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.MOBILE_CLIENT_PLATFORM; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_CLIENT_ID; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_CLIENT_NAME; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_CLIENT_PLATFORM; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_CLIENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_DEVICE_MAKE; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_DEVICE_MODEL_AND_OS_NAME; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WATCH_CLIENT_SCREEN; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_CLIENT_ID; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_CLIENT_NAME; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_EMBEDDED_CLIENT_ID; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_EMBEDDED_CLIENT_NAME; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_HARDCODED_CLIENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_REMIX_HARDCODED_CLIENT_VERSION; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +// TODO: add docs + +public final class InnertubeClientRequestInfo { + + @Nonnull + public ClientInfo clientInfo; + @Nonnull + public DeviceInfo deviceInfo; + + public static final class ClientInfo { + + @Nonnull + public String clientName; + @Nonnull + public String clientVersion; + @Nonnull + public String clientScreen; + @Nullable + public String clientId; + @Nullable + public String visitorData; + + private ClientInfo(@Nonnull final String clientName, + @Nonnull final String clientVersion, + @Nonnull final String clientScreen, + @Nullable final String clientId, + @Nullable final String visitorData) { + this.clientName = clientName; + this.clientVersion = clientVersion; + this.clientScreen = clientScreen; + this.clientId = clientId; + this.visitorData = visitorData; + } + } + + public static final class DeviceInfo { + + @Nonnull + public String platform; + @Nullable + public String deviceMake; + @Nullable + public String deviceModel; + @Nullable + public String osName; + @Nullable + public String osVersion; + public int androidSdkVersion; + + private DeviceInfo(@Nonnull final String platform, + @Nullable final String deviceMake, + @Nullable final String deviceModel, + @Nullable final String osName, + @Nullable final String osVersion, + final int androidSdkVersion) { + this.platform = platform; + this.deviceMake = deviceMake; + this.deviceModel = deviceModel; + this.osName = osName; + this.osVersion = osVersion; + this.androidSdkVersion = androidSdkVersion; + } + } + + private InnertubeClientRequestInfo(@Nonnull final ClientInfo clientInfo, + @Nonnull final DeviceInfo deviceInfo) { + this.clientInfo = clientInfo; + this.deviceInfo = deviceInfo; + } + + @Nonnull + public static InnertubeClientRequestInfo ofWebClient() { + return new InnertubeClientRequestInfo( + new InnertubeClientRequestInfo.ClientInfo( + WEB_CLIENT_NAME, WEB_HARDCODED_CLIENT_VERSION, WATCH_CLIENT_SCREEN, + WEB_CLIENT_ID, null), + new InnertubeClientRequestInfo.DeviceInfo(DESKTOP_CLIENT_PLATFORM, null, null, + null, null, -1)); + } + + @Nonnull + public static InnertubeClientRequestInfo ofWebEmbeddedPlayerClient() { + return new InnertubeClientRequestInfo( + new InnertubeClientRequestInfo.ClientInfo(WEB_EMBEDDED_CLIENT_NAME, + WEB_REMIX_HARDCODED_CLIENT_VERSION, EMBED_CLIENT_SCREEN, + WEB_EMBEDDED_CLIENT_ID, null), + new InnertubeClientRequestInfo.DeviceInfo(DESKTOP_CLIENT_PLATFORM, null, null, + null, null, -1)); + } + + @Nonnull + public static InnertubeClientRequestInfo ofTvHtml5Client() { + return new InnertubeClientRequestInfo( + new InnertubeClientRequestInfo.ClientInfo(TVHTML5_CLIENT_NAME, + TVHTML5_CLIENT_VERSION, WATCH_CLIENT_SCREEN, TVHTML5_CLIENT_ID, null), + new InnertubeClientRequestInfo.DeviceInfo(TVHTML5_CLIENT_PLATFORM, + TVHTML5_DEVICE_MAKE, TVHTML5_DEVICE_MODEL_AND_OS_NAME, + TVHTML5_DEVICE_MODEL_AND_OS_NAME, "", -1)); + } + + @Nonnull + public static InnertubeClientRequestInfo ofAndroidClient() { + return new InnertubeClientRequestInfo( + new InnertubeClientRequestInfo.ClientInfo(ANDROID_CLIENT_NAME, + ANDROID_CLIENT_VERSION, WATCH_CLIENT_SCREEN, ANDROID_CLIENT_ID, null), + new InnertubeClientRequestInfo.DeviceInfo(MOBILE_CLIENT_PLATFORM, null, null, + "Android", "15", 35)); + } + + @Nonnull + public static InnertubeClientRequestInfo ofIosClient() { + return new InnertubeClientRequestInfo( + new InnertubeClientRequestInfo.ClientInfo(IOS_CLIENT_NAME, IOS_CLIENT_VERSION, + WATCH_CLIENT_SCREEN, IOS_CLIENT_ID, null), + new InnertubeClientRequestInfo.DeviceInfo(MOBILE_CLIENT_PLATFORM, "Apple", + IOS_DEVICE_MODEL, "iOS", IOS_OS_VERSION, -1)); + } +} 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 000000000..a11a25bae --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenProvider.java @@ -0,0 +1,120 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import javax.annotation.Nullable; + +/** + * Interface to provide {@code poToken}s 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 + * one or multiple {@code poToken}(s) (which stands for proof of origin token(s)). + *

+ * + *

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

+ * + *

+ * If an implementation does not want to return a {@code poToken} for a specific client, it must + * return {@code null}. + *

+ * + *

+ * Implementations of this interface are expected to be thread-safe, as they 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, {@code poToken}s 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. + *

+ * + *

+ * Note that YouTube desktop website generates two {@code poToken}s: + * - one for the player requests {@code poToken}s, using the videoId as the minter value; + * - one for the streaming URLs, using a visitor data for logged-out users as the minter value. + *

+ * + * @return a {@link PoTokenResult} specific to the WEB InnerTube client + */ + @Nullable + PoTokenResult getWebClientPoToken(String videoId); + + /** + * Get a {@link PoTokenResult} specific to the web embeds, a.k.a. the WEB_EMBEDDED_PLAYER + * InnerTube client. + * + *

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

+ * + *

+ * As of writing, like the YouTube desktop website previously did, it generates only one + * {@code poToken}, sent in player requests and streaming URLs, using a visitor data for + * logged-out users. {@code poToken}s do not seem to be mandatory for now on this client. + *

+ * + * @return a {@link PoTokenResult} specific to the WEB_EMBEDDED_PLAYER InnerTube client + */ + @Nullable + PoTokenResult getWebEmbedClientPoToken(String videoId); + + /** + * Get a {@link PoTokenResult} specific to the Android app, a.k.a. the ANDROID InnerTube client. + * + *

+ * Implementation details are not known, the app uses DroidGuard, a downloaded native virtual + * machine ran by Google Play Services for which its code is updated pretty frequently. + *

+ * + *

+ * As of writing, DroidGuard seem to check for the Android app signature and package ID, as + * non-rooted YouTube patched with reVanced doesn't work without spoofing another InnerTube + * client while the rooted version works without any client spoofing. + *

+ * + *

+ * There should be only one {@code poToken} needed for the player requests, it shouldn't be + * required for regular adaptive URLs (i.e. not server adaptive bitrate (SABR) URLs). HLS + * formats returned (only for premieres and running and post-live livestreams) in the client's + * HLS manifest URL should work without {@code poToken}s. + *

+ * + * @return a {@link PoTokenResult} specific to the ANDROID InnerTube client + */ + @Nullable + PoTokenResult getAndroidClientPoToken(String videoId); + + /** + * Get a {@link PoTokenResult} specific to the iOS app, a.k.a. the IOS InnerTube client. + * + *

+ * Implementation details are not known, the app seem to use something called iosGuard which + * should be similar to Android's DroidGuard. It may rely on Apple's attestation APIs. + *

+ * + *

+ * As of writing, there should be only one {@code poToken} needed for the player requests, it + * shouldn't be required for regular adaptive URLs (i.e. not server adaptive bitrate (SABR) + * URLs). HLS formats returned in the client's HLS manifest URL should also work without a + * {@code poToken}. + *

+ * + * @return a {@link PoTokenResult} specific to the IOS InnerTube client + */ + @Nullable + PoTokenResult getIosClientPoToken(String videoId); +} 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 000000000..4ccbaf1ba --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/PoTokenResult.java @@ -0,0 +1,52 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Objects; + +/** + * The result of a supported/successful {@code poToken} extraction request by a + * {@link PoTokenProvider}. + */ +public final class PoTokenResult { + + /** + * The visitor data associated with a {@code poToken}. + */ + @Nonnull + public final String visitorData; + + /** + * The {@code poToken} of a player request, a Protobuf object encoded as a base 64 string. + */ + @Nonnull + public final String playerRequestPoToken; + + /** + * The {@code poToken} to be appended to streaming URLs, a Protobuf object encoded as a base + * 64 string. + * + *

+ * It may be required on some clients such as HTML5 ones and may also differ from the player + * request {@code poToken}. + *

+ */ + @Nullable + public final String streamingDataPoToken; + + /** + * Construct a {@link PoTokenResult} instance. + * + * @param visitorData see {@link #visitorData} + * @param playerRequestPoToken see {@link #playerRequestPoToken} + * @param streamingDataPoToken see {@link #streamingDataPoToken} + * @throws NullPointerException if a non-null parameter is null + */ + public PoTokenResult(@Nonnull final String visitorData, + @Nonnull final String playerRequestPoToken, + @Nullable final String streamingDataPoToken) { + this.visitorData = Objects.requireNonNull(visitorData); + this.playerRequestPoToken = Objects.requireNonNull(playerRequestPoToken); + this.streamingDataPoToken = streamingDataPoToken; + } +} 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 54b4b90fb..da5870833 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 @@ -21,6 +21,18 @@ package org.schabi.newpipe.extractor.services.youtube; import static org.schabi.newpipe.extractor.NewPipe.getDownloader; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.ANDROID_CLIENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.DESKTOP_CLIENT_PLATFORM; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_CLIENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_DEVICE_MODEL; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_USER_AGENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_USER_AGENT; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_CLIENT_ID; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_CLIENT_NAME; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_HARDCODED_CLIENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_REMIX_CLIENT_ID; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_REMIX_CLIENT_NAME; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_REMIX_HARDCODED_CLIENT_VERSION; import static org.schabi.newpipe.extractor.utils.Utils.HTTP; import static org.schabi.newpipe.extractor.utils.Utils.HTTPS; import static org.schabi.newpipe.extractor.utils.Utils.getStringResultFromRegexArray; @@ -144,55 +156,6 @@ public final class YoutubeParsingHelper { */ public static final String RACY_CHECK_OK = "racyCheckOk"; - /** - * The hardcoded client ID used for InnerTube requests with the {@code WEB} client. - */ - private static final String WEB_CLIENT_ID = "1"; - - /** - * The client version for InnerTube requests with the {@code WEB} client, used as the last - * fallback if the extraction of the real one failed. - */ - private static final String HARDCODED_CLIENT_VERSION = "2.20240718.01.00"; - - /** - * The hardcoded client version of the Android app used for InnerTube requests with this - * client. - * - *

- * It can be extracted by getting the latest release version of the app in an APK repository - * such as APKMirror. - *

- */ - private static final String ANDROID_YOUTUBE_CLIENT_VERSION = "19.28.35"; - - /** - * The hardcoded client version of the iOS app used for InnerTube requests with this client. - * - *

- * It can be extracted by getting the latest release version of the app on - * the App - * Store page of the YouTube app, in the {@code What’s New} section. - *

- */ - private static final String IOS_YOUTUBE_CLIENT_VERSION = "20.03.02"; - - /** - * The hardcoded client version used for InnerTube requests with the TV HTML5 embed client. - */ - private static final String TVHTML5_SIMPLY_EMBED_CLIENT_VERSION = "2.0"; - - /** - * The hardcoded client ID used for InnerTube requests with the YouTube Music desktop client. - */ - private static final String YOUTUBE_MUSIC_CLIENT_ID = "67"; - - /** - * The hardcoded client version used for InnerTube requests with the YouTube Music desktop - * client. - */ - private static final String HARDCODED_YOUTUBE_MUSIC_CLIENT_VERSION = "1.20240715.01.00"; - private static String clientVersion; private static String youtubeMusicClientVersion; @@ -212,49 +175,16 @@ public final class YoutubeParsingHelper { private static final String CONTENT_PLAYBACK_NONCE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - /** - * The device machine id for the iPhone 15 Pro Max, - * used to get 60fps with the {@code iOS} client. - * - *

- * See this GitHub Gist for more - * information. - *

- */ - private static final String IOS_DEVICE_MODEL = "iPhone16,2"; - - /** - * Spoofing an iPhone 15 Pro Max running iOS 18.2.1 with the hardcoded version of the iOS app. - * To be used for the {@code "osVersion"} field in JSON POST requests. - *

- * The value of this field seems to use the following structure: - * "iOS major version.minor version.patch version.build version", where - * "patch version" is equal to 0 if it isn't set - * The build version corresponding to the iOS version used can be found on - * - * https://theapplewiki.com/wiki/Firmware/iPhone/18.x#iPhone_15_Pro_Max - *

- * - * @see #IOS_USER_AGENT_VERSION - */ - private static final String IOS_OS_VERSION = "18.2.1.22C161"; - - /** - * Spoofing an iPhone 15 Pro Max running iOS 18.2.1 with the hardcoded version of the iOS app. - * To be used in the user agent for requests. - * - * @see #IOS_OS_VERSION - */ - private static final String IOS_USER_AGENT_VERSION = "18_2_1"; - private static Random numberGenerator = new Random(); private static final String FEED_BASE_CHANNEL_ID = "https://www.youtube.com/feeds/videos.xml?channel_id="; private static final String FEED_BASE_USER = "https://www.youtube.com/feeds/videos.xml?user="; private static final Pattern C_WEB_PATTERN = Pattern.compile("&c=WEB"); - private static final Pattern C_TVHTML5_SIMPLY_EMBEDDED_PLAYER_PATTERN = - Pattern.compile("&c=TVHTML5_SIMPLY_EMBEDDED_PLAYER"); + private static final Pattern C_WEB_EMBEDDED_PLAYER_PATTERN = + Pattern.compile("&c=WEB_EMBEDDED_PLAYER"); + private static final Pattern C_TVHTML5_PLAYER_PATTERN = + Pattern.compile("&c=TVHTML5"); private static final Pattern C_ANDROID_PATTERN = Pattern.compile("&c=ANDROID"); private static final Pattern C_IOS_PATTERN = Pattern.compile("&c=IOS"); @@ -561,9 +491,9 @@ public final class YoutubeParsingHelper { .object("client") .value("hl", "en-GB") .value("gl", "GB") - .value("clientName", "WEB") - .value("clientVersion", HARDCODED_CLIENT_VERSION) - .value("platform", "DESKTOP") + .value("clientName", WEB_CLIENT_NAME) + .value("clientVersion", WEB_HARDCODED_CLIENT_VERSION) + .value("platform", DESKTOP_CLIENT_PLATFORM) .value("utcOffsetMinutes", 0) .end() .object("request") @@ -581,7 +511,7 @@ public final class YoutubeParsingHelper { .end().done().getBytes(StandardCharsets.UTF_8); // @formatter:on - final var headers = getClientHeaders(WEB_CLIENT_ID, HARDCODED_CLIENT_VERSION); + final var headers = getClientHeaders(WEB_CLIENT_ID, WEB_HARDCODED_CLIENT_VERSION); // This endpoint is fetched by the YouTube website to get the items of its main menu and is // pretty lightweight (around 30kB) @@ -705,7 +635,7 @@ public final class YoutubeParsingHelper { // Fallback to the hardcoded one if it is valid if (isHardcodedClientVersionValid()) { - clientVersion = HARDCODED_CLIENT_VERSION; + clientVersion = WEB_HARDCODED_CLIENT_VERSION; return clientVersion; } @@ -752,11 +682,11 @@ public final class YoutubeParsingHelper { .object() .object("context") .object("client") - .value("clientName", "WEB_REMIX") - .value("clientVersion", HARDCODED_YOUTUBE_MUSIC_CLIENT_VERSION) + .value("clientName", WEB_REMIX_CLIENT_NAME) + .value("clientVersion", WEB_REMIX_HARDCODED_CLIENT_VERSION) .value("hl", "en-GB") .value("gl", "GB") - .value("platform", "DESKTOP") + .value("platform", DESKTOP_CLIENT_PLATFORM) .value("utcOffsetMinutes", 0) .end() .object("request") @@ -775,8 +705,7 @@ public final class YoutubeParsingHelper { // @formatter:on final var headers = new HashMap<>(getOriginReferrerHeaders(YOUTUBE_MUSIC_URL)); - headers.putAll(getClientHeaders(YOUTUBE_MUSIC_CLIENT_ID, - HARDCODED_YOUTUBE_MUSIC_CLIENT_VERSION)); + headers.putAll(getClientHeaders(WEB_REMIX_CLIENT_ID, WEB_HARDCODED_CLIENT_VERSION)); final Response response = getDownloader().postWithContentTypeJson(url, headers, json); // Ensure to have a valid response @@ -789,7 +718,7 @@ public final class YoutubeParsingHelper { return youtubeMusicClientVersion; } if (isHardcodedYoutubeMusicClientVersionValid()) { - youtubeMusicClientVersion = HARDCODED_YOUTUBE_MUSIC_CLIENT_VERSION; + youtubeMusicClientVersion = WEB_REMIX_HARDCODED_CLIENT_VERSION; return youtubeMusicClientVersion; } @@ -1134,116 +1063,20 @@ public final class YoutubeParsingHelper { + DISABLE_PRETTY_PRINT_PARAMETER, headers, body, localization))); } - public static JsonObject getJsonAndroidPostResponse( - final String endpoint, - final byte[] body, - @Nonnull final Localization localization, - @Nullable final String endPartOfUrlRequest) throws IOException, ExtractionException { - return getMobilePostResponse(endpoint, body, localization, - getAndroidUserAgent(localization), endPartOfUrlRequest); - } - - public static JsonObject getJsonIosPostResponse( - final String endpoint, - final byte[] body, - @Nonnull final Localization localization, - @Nullable final String endPartOfUrlRequest) throws IOException, ExtractionException { - return getMobilePostResponse(endpoint, body, localization, getIosUserAgent(localization), - endPartOfUrlRequest); - } - - private static JsonObject getMobilePostResponse( - final String endpoint, - final byte[] body, - @Nonnull final Localization localization, - @Nonnull final String userAgent, - @Nullable final String endPartOfUrlRequest) throws IOException, ExtractionException { - final var headers = Map.of("User-Agent", List.of(userAgent), - "X-Goog-Api-Format-Version", List.of("2")); - - final String baseEndpointUrl = YOUTUBEI_V1_GAPIS_URL + endpoint + "?" - + DISABLE_PRETTY_PRINT_PARAMETER; - - return JsonUtils.toJsonObject(getValidJsonResponseBody( - getDownloader().postWithContentTypeJson(isNullOrEmpty(endPartOfUrlRequest) - ? baseEndpointUrl - : baseEndpointUrl + endPartOfUrlRequest, - headers, body, localization))); - } - @Nonnull public static JsonBuilder prepareDesktopJsonBuilder( @Nonnull final Localization localization, - @Nonnull final ContentCountry contentCountry) - throws IOException, ExtractionException { - return prepareDesktopJsonBuilder(localization, contentCountry, null); - } - - @Nonnull - public static JsonBuilder prepareDesktopJsonBuilder( - @Nonnull final Localization localization, - @Nonnull final ContentCountry contentCountry, - @Nullable final String visitorData) - throws IOException, ExtractionException { - String vData = visitorData; - if (vData == null) { - vData = randomVisitorData(contentCountry); - } - + @Nonnull final ContentCountry contentCountry) throws IOException, ExtractionException { // @formatter:off return JsonObject.builder() .object("context") .object("client") .value("hl", localization.getLocalizationCode()) .value("gl", contentCountry.getCountryCode()) - .value("clientName", "WEB") + .value("clientName", WEB_CLIENT_NAME) .value("clientVersion", getClientVersion()) .value("originalUrl", "https://www.youtube.com") - .value("platform", "DESKTOP") - .value("utcOffsetMinutes", 0) - .value("visitorData", vData) - .end() - .object("request") - .array("internalExperimentFlags") - .end() - .value("useSsl", true) - .end() - .object("user") - // TODO: provide a way to enable restricted mode with: - // .value("enableSafetyMode", boolean) - .value("lockedSafetyMode", false) - .end() - .end(); - // @formatter:on - } - - @Nonnull - public static JsonBuilder prepareAndroidMobileJsonBuilder( - @Nonnull final Localization localization, - @Nonnull final ContentCountry contentCountry) { - // @formatter:off - return JsonObject.builder() - .object("context") - .object("client") - .value("clientName", "ANDROID") - .value("clientVersion", ANDROID_YOUTUBE_CLIENT_VERSION) - .value("platform", "MOBILE") - .value("osName", "Android") - .value("osVersion", "14") - /* - A valid Android SDK version is required to be sure to get a valid player - response - If this parameter is not provided, the player response is replaced by an - error saying the message "The following content is not available on this - app. Watch this content on the latest version on YouTube" (it was - previously a 5-minute video with this message) - See https://github.com/TeamNewPipe/NewPipe/issues/8713 - The Android SDK version corresponding to the Android version used in - requests is sent - */ - .value("androidSdkVersion", 34) - .value("hl", localization.getLocalizationCode()) - .value("gl", contentCountry.getCountryCode()) + .value("platform", DESKTOP_CLIENT_PLATFORM) .value("utcOffsetMinutes", 0) .end() .object("request") @@ -1260,122 +1093,6 @@ public final class YoutubeParsingHelper { // @formatter:on } - @Nonnull - public static JsonBuilder prepareIosMobileJsonBuilder( - @Nonnull final Localization localization, - @Nonnull final ContentCountry contentCountry) { - // @formatter:off - return JsonObject.builder() - .object("context") - .object("client") - .value("clientName", "IOS") - .value("clientVersion", IOS_YOUTUBE_CLIENT_VERSION) - .value("deviceMake", "Apple") - // Device model is required to get 60fps streams - .value("deviceModel", IOS_DEVICE_MODEL) - .value("platform", "MOBILE") - .value("osName", "iOS") - .value("osVersion", IOS_OS_VERSION) - .value("visitorData", randomVisitorData(contentCountry)) - .value("hl", localization.getLocalizationCode()) - .value("gl", contentCountry.getCountryCode()) - .value("utcOffsetMinutes", 0) - .end() - .object("request") - .array("internalExperimentFlags") - .end() - .value("useSsl", true) - .end() - .object("user") - // TODO: provide a way to enable restricted mode with: - // .value("enableSafetyMode", boolean) - .value("lockedSafetyMode", false) - .end() - .end(); - // @formatter:on - } - - @Nonnull - public static JsonBuilder prepareTvHtml5EmbedJsonBuilder( - @Nonnull final Localization localization, - @Nonnull final ContentCountry contentCountry, - @Nonnull final String videoId) { - // @formatter:off - return JsonObject.builder() - .object("context") - .object("client") - .value("clientName", "TVHTML5_SIMPLY_EMBEDDED_PLAYER") - .value("clientVersion", TVHTML5_SIMPLY_EMBED_CLIENT_VERSION) - .value("clientScreen", "EMBED") - .value("platform", "TV") - .value("hl", localization.getLocalizationCode()) - .value("gl", contentCountry.getCountryCode()) - .value("utcOffsetMinutes", 0) - .end() - .object("thirdParty") - .value("embedUrl", "https://www.youtube.com/watch?v=" + videoId) - .end() - .object("request") - .array("internalExperimentFlags") - .end() - .value("useSsl", true) - .end() - .object("user") - // TODO: provide a way to enable restricted mode with: - // .value("enableSafetyMode", boolean) - .value("lockedSafetyMode", false) - .end() - .end(); - // @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, - @Nonnull final ContentCountry contentCountry, - @Nonnull final String videoId, - @Nonnull final Integer sts, - @Nonnull final String contentPlaybackNonce) { - // @formatter:off - return JsonWriter.string( - prepareTvHtml5EmbedJsonBuilder(localization, contentCountry, videoId) - .object("playbackContext") - .object("contentPlaybackContext") - // Signature timestamp from the JavaScript base player is needed to get - // working obfuscated URLs - .value("signatureTimestamp", sts) - .value("referer", "https://www.youtube.com/watch?v=" + videoId) - .end() - .end() - .value(CPN, contentPlaybackNonce) - .value(VIDEO_ID, videoId) - .value(CONTENT_CHECK_OK, true) - .value(RACY_CHECK_OK, true) - .done()) - .getBytes(StandardCharsets.UTF_8); - // @formatter:on - } - /** * Get the user-agent string used as the user-agent for InnerTube requests with the Android * client. @@ -1391,9 +1108,8 @@ public final class YoutubeParsingHelper { */ @Nonnull public static String getAndroidUserAgent(@Nullable final Localization localization) { - // Spoofing an Android 14 device with the hardcoded version of the Android app - return "com.google.android.youtube/" + ANDROID_YOUTUBE_CLIENT_VERSION - + " (Linux; U; Android 14; " + return "com.google.android.youtube/" + ANDROID_CLIENT_VERSION + + " (Linux; U; Android 15; " + (localization != null ? localization : Localization.DEFAULT).getCountryCode() + ") gzip"; } @@ -1413,23 +1129,30 @@ public final class YoutubeParsingHelper { */ @Nonnull public static String getIosUserAgent(@Nullable final Localization localization) { - // Spoofing an iPhone 15 Pro Max running iOS 18.1.0 - // with the hardcoded version of the iOS app - return "com.google.ios.youtube/" + IOS_YOUTUBE_CLIENT_VERSION - + "(" + IOS_DEVICE_MODEL + "; U; CPU iOS " - + IOS_USER_AGENT_VERSION + " like Mac OS X; " + return "com.google.ios.youtube/" + IOS_CLIENT_VERSION + "(" + IOS_DEVICE_MODEL + + "; U; CPU iOS " + IOS_USER_AGENT_VERSION + " like Mac OS X; " + (localization != null ? localization : Localization.DEFAULT).getCountryCode() + ")"; } + /** + * Get the user-agent string used as the user-agent for InnerTube requests with the HTML5 TV + * client. + * + * @return the user-agent used for InnerTube requests with the TVHTML5 client + */ + @Nonnull + public static String getTvHtml5UserAgent() { + return TVHTML5_USER_AGENT; + } + /** * Returns a {@link Map} containing the required YouTube Music headers. */ @Nonnull public static Map> getYoutubeMusicHeaders() { final var headers = new HashMap<>(getOriginReferrerHeaders(YOUTUBE_MUSIC_URL)); - headers.putAll(getClientHeaders(YOUTUBE_MUSIC_CLIENT_ID, - youtubeMusicClientVersion)); + headers.putAll(getClientHeaders(WEB_REMIX_CLIENT_ID, youtubeMusicClientVersion)); return headers; } @@ -1461,7 +1184,7 @@ public final class YoutubeParsingHelper { * * @param url The URL to be set as the origin and referrer. */ - private static Map> getOriginReferrerHeaders(@Nonnull final String url) { + public static Map> getOriginReferrerHeaders(@Nonnull final String url) { final var urlList = List.of(url); return Map.of("Origin", urlList, "Referer", urlList); } @@ -1473,8 +1196,8 @@ public final class YoutubeParsingHelper { * @param name The X-YouTube-Client-Name value. * @param version X-YouTube-Client-Version value. */ - private static Map> getClientHeaders(@Nonnull final String name, - @Nonnull final String version) { + static Map> getClientHeaders(@Nonnull final String name, + @Nonnull final String version) { return Map.of("X-YouTube-Client-Name", List.of(name), "X-YouTube-Client-Version", List.of(version)); } @@ -1669,15 +1392,24 @@ public final class YoutubeParsingHelper { } /** - * Check if the streaming URL is a URL from the YouTube {@code TVHTML5_SIMPLY_EMBEDDED_PLAYER} - * client. + * Check if the streaming URL is from the YouTube {@code WEB_EMBEDDED_PLAYER} client. * - * @param url the streaming URL on which check if it's a {@code TVHTML5_SIMPLY_EMBEDDED_PLAYER} - * streaming URL. - * @return true if it's a {@code TVHTML5_SIMPLY_EMBEDDED_PLAYER} streaming URL, false otherwise + * @param url the streaming URL to be checked. + * @return true if it's a {@code WEB_EMBEDDED_PLAYER} streaming URL, false otherwise */ - public static boolean isTvHtml5SimplyEmbeddedPlayerStreamingUrl(@Nonnull final String url) { - return Parser.isMatch(C_TVHTML5_SIMPLY_EMBEDDED_PLAYER_PATTERN, url); + public static boolean isWebEmbeddedPlayerStreamingUrl(@Nonnull final String url) { + return Parser.isMatch(C_WEB_EMBEDDED_PLAYER_PATTERN, url); + } + + /** + * Check if the streaming URL is a URL from the YouTube {@code TVHTML5} client. + * + * @param url the streaming URL on which check if it's a {@code TVHTML5} + * streaming URL. + * @return true if it's a {@code TVHTML5} streaming URL, false otherwise + */ + public static boolean isTvHtml5StreamingUrl(@Nonnull final String url) { + return Parser.isMatch(C_TVHTML5_PLAYER_PATTERN, url); } /** @@ -1775,4 +1507,96 @@ public final class YoutubeParsingHelper { return null; } } + + @Nonnull + public static String getVisitorDataFromInnertube( + @Nonnull final InnertubeClientRequestInfo innertubeClientRequestInfo, + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final Map> httpHeaders, + @Nonnull final String innertubeDomainAndVersionEndpoint, + @Nullable final String embedUrl, + final boolean useGuideEndpoint) throws IOException, ExtractionException { + final JsonBuilder builder = prepareJsonBuilder( + localization, contentCountry, innertubeClientRequestInfo, embedUrl); + + final byte[] body = JsonWriter.string(builder.done()) + .getBytes(StandardCharsets.UTF_8); + + final String visitorData = JsonUtils.toJsonObject(getValidJsonResponseBody(getDownloader() + .postWithContentTypeJson( + innertubeDomainAndVersionEndpoint + + (useGuideEndpoint ? "guide" : "visitor_id") + "?" + + DISABLE_PRETTY_PRINT_PARAMETER, + httpHeaders, body))) + .getObject("responseContext") + .getString("visitorData"); + + if (isNullOrEmpty(visitorData)) { + throw new ParsingException("Could not get visitorData"); + } + + return visitorData; + } + + @Nonnull + static JsonBuilder prepareJsonBuilder( + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final InnertubeClientRequestInfo innertubeClientRequestInfo, + @Nullable final String embedUrl) { + final JsonBuilder builder = JsonObject.builder() + .object("context") + .object("client") + .value("clientName", innertubeClientRequestInfo.clientInfo.clientName) + .value("clientVersion", innertubeClientRequestInfo.clientInfo.clientVersion) + .value("clientScreen", innertubeClientRequestInfo.clientInfo.clientScreen) + .value("platform", innertubeClientRequestInfo.deviceInfo.platform); + + if (innertubeClientRequestInfo.clientInfo.visitorData != null) { + builder.value("visitorData", innertubeClientRequestInfo.clientInfo.visitorData); + } + + if (innertubeClientRequestInfo.deviceInfo.deviceMake != null) { + builder.value("deviceMake", innertubeClientRequestInfo.deviceInfo.deviceMake); + } + if (innertubeClientRequestInfo.deviceInfo.deviceModel != null) { + builder.value("deviceModel", innertubeClientRequestInfo.deviceInfo.deviceModel); + } + if (innertubeClientRequestInfo.deviceInfo.osName != null) { + builder.value("osName", innertubeClientRequestInfo.deviceInfo.osName); + } + if (innertubeClientRequestInfo.deviceInfo.osVersion != null) { + builder.value("osVersion", innertubeClientRequestInfo.deviceInfo.osVersion); + } + if (innertubeClientRequestInfo.deviceInfo.androidSdkVersion > 0) { + builder.value("androidSdkVersion", + innertubeClientRequestInfo.deviceInfo.androidSdkVersion); + } + + builder.value("hl", localization.getLocalizationCode()) + .value("gl", contentCountry.getCountryCode()) + .value("utcOffsetMinutes", 0) + .end(); + + if (embedUrl != null) { + builder.object("thirdParty") + .value("embedUrl", embedUrl) + .end(); + } + + builder.object("request") + .array("internalExperimentFlags") + .end() + .value("useSsl", true) + .end() + .object("user") + // TODO: provide a way to enable restricted mode with: + // .value("enableSafetyMode", boolean) + .value("lockedSafetyMode", false) + .end() + .end(); + + return builder; + } } 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 000000000..1eb56f250 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeStreamHelper.java @@ -0,0 +1,344 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import com.grack.nanojson.JsonBuilder; +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 javax.annotation.Nullable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.schabi.newpipe.extractor.NewPipe.getDownloader; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_CLIENT_ID; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_CLIENT_VERSION; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_USER_AGENT; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_EMBEDDED_CLIENT_ID; +import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_EMBEDDED_CLIENT_VERSION; +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_GAPIS_URL; +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.getAndroidUserAgent; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getClientHeaders; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getClientVersion; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getOriginReferrerHeaders; +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.prepareJsonBuilder; + +public final class YoutubeStreamHelper { + + private static final String PLAYER = "player"; + private static final String SERVICE_INTEGRITY_DIMENSIONS = "serviceIntegrityDimensions"; + private static final String PO_TOKEN = "poToken"; + private static final String BASE_YT_DESKTOP_WATCH_URL = "https://www.youtube.com/watch?v="; + + private YoutubeStreamHelper() { + } + + @Nonnull + public static JsonObject getWebMetadataPlayerResponse( + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId) throws IOException, ExtractionException { + final InnertubeClientRequestInfo innertubeClientRequestInfo = + InnertubeClientRequestInfo.ofWebClient(); + innertubeClientRequestInfo.clientInfo.clientVersion = getClientVersion(); + + final Map> headers = getYouTubeHeaders(); + + // We must always pass a valid visitorData to get valid player responses, which needs to be + // got from YouTube + innertubeClientRequestInfo.clientInfo.visitorData = + YoutubeParsingHelper.getVisitorDataFromInnertube(innertubeClientRequestInfo, + localization, contentCountry, headers, YOUTUBEI_V1_URL, null, false); + + final JsonBuilder builder = prepareJsonBuilder(localization, contentCountry, + innertubeClientRequestInfo, null); + + addVideoIdCpnAndOkChecks(builder, videoId, null); + + final byte[] body = JsonWriter.string(builder.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, headers, body, localization))); + } + + @Nonnull + public static JsonObject getTvHtml5PlayerResponse( + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nonnull final String cpn, + final int signatureTimestamp) throws IOException, ExtractionException { + final InnertubeClientRequestInfo innertubeClientRequestInfo = + InnertubeClientRequestInfo.ofTvHtml5Client(); + + final Map> headers = new HashMap<>( + getClientHeaders(TVHTML5_CLIENT_ID, TVHTML5_CLIENT_VERSION)); + headers.putAll(getOriginReferrerHeaders("https://www.youtube.com")); + headers.put("User-Agent", List.of(TVHTML5_USER_AGENT)); + + // We must always pass a valid visitorData to get valid player responses, which needs to be + // got from YouTube + // For some reason, the TVHTML5 client doesn't support the visitor_id endpoint, use the + // guide one instead, which is quite lightweight + innertubeClientRequestInfo.clientInfo.visitorData = + YoutubeParsingHelper.getVisitorDataFromInnertube(innertubeClientRequestInfo, + localization, contentCountry, headers, YOUTUBEI_V1_URL, null, true); + + final JsonBuilder builder = prepareJsonBuilder(localization, contentCountry, + innertubeClientRequestInfo, null); + + addVideoIdCpnAndOkChecks(builder, videoId, cpn); + + addPlaybackContext(builder, BASE_YT_DESKTOP_WATCH_URL + videoId, signatureTimestamp); + + final byte[] body = JsonWriter.string(builder.done()) + .getBytes(StandardCharsets.UTF_8); + + final String url = YOUTUBEI_V1_URL + PLAYER + "?" + DISABLE_PRETTY_PRINT_PARAMETER; + + return JsonUtils.toJsonObject(getValidJsonResponseBody( + getDownloader().postWithContentTypeJson(url, headers, body, localization))); + } + + @Nonnull + public static JsonObject getWebFullPlayerResponse( + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nonnull final String cpn, + @Nonnull final PoTokenResult webPoTokenResult, + final int signatureTimestamp) throws IOException, ExtractionException { + final InnertubeClientRequestInfo innertubeClientRequestInfo = + InnertubeClientRequestInfo.ofWebClient(); + innertubeClientRequestInfo.clientInfo.clientVersion = getClientVersion(); + innertubeClientRequestInfo.clientInfo.visitorData = webPoTokenResult.visitorData; + + final JsonBuilder builder = prepareJsonBuilder(localization, contentCountry, + innertubeClientRequestInfo, null); + + addVideoIdCpnAndOkChecks(builder, videoId, cpn); + + addPlaybackContext(builder, BASE_YT_DESKTOP_WATCH_URL + videoId, signatureTimestamp); + + addPoToken(builder, webPoTokenResult.playerRequestPoToken); + + final byte[] body = JsonWriter.string(builder.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))); + } + + @Nonnull + public static JsonObject getWebEmbeddedPlayerResponse( + @Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nonnull final String cpn, + @Nullable final PoTokenResult webEmbeddedPoTokenResult, + final int signatureTimestamp) throws IOException, ExtractionException { + final InnertubeClientRequestInfo innertubeClientRequestInfo = + InnertubeClientRequestInfo.ofWebEmbeddedPlayerClient(); + + final Map> headers = new HashMap<>( + getClientHeaders(WEB_EMBEDDED_CLIENT_ID, WEB_EMBEDDED_CLIENT_VERSION)); + headers.putAll(getOriginReferrerHeaders("https://www.youtube.com")); + + final String embedUrl = BASE_YT_DESKTOP_WATCH_URL + videoId; + + // We must always pass a valid visitorData to get valid player responses, which needs to be + // got from YouTube + innertubeClientRequestInfo.clientInfo.visitorData = webEmbeddedPoTokenResult == null + ? YoutubeParsingHelper.getVisitorDataFromInnertube(innertubeClientRequestInfo, + localization, contentCountry, headers, YOUTUBEI_V1_URL, embedUrl, false) + : webEmbeddedPoTokenResult.visitorData; + + final JsonBuilder builder = prepareJsonBuilder(localization, contentCountry, + innertubeClientRequestInfo, embedUrl); + + addVideoIdCpnAndOkChecks(builder, videoId, cpn); + + addPlaybackContext(builder, embedUrl, signatureTimestamp); + + if (webEmbeddedPoTokenResult != null) { + addPoToken(builder, webEmbeddedPoTokenResult.playerRequestPoToken); + } + + final byte[] body = JsonWriter.string(builder.done()) + .getBytes(StandardCharsets.UTF_8); + final String url = YOUTUBEI_V1_URL + PLAYER + "?" + DISABLE_PRETTY_PRINT_PARAMETER; + + return JsonUtils.toJsonObject(getValidJsonResponseBody( + getDownloader().postWithContentTypeJson(url, headers, body, localization))); + } + + public static JsonObject getAndroidPlayerResponse( + @Nonnull final ContentCountry contentCountry, + @Nonnull final Localization localization, + @Nonnull final String videoId, + @Nonnull final String cpn, + @Nonnull final PoTokenResult androidPoTokenResult) + throws IOException, ExtractionException { + final InnertubeClientRequestInfo innertubeClientRequestInfo = + InnertubeClientRequestInfo.ofAndroidClient(); + innertubeClientRequestInfo.clientInfo.visitorData = androidPoTokenResult.visitorData; + + final Map> headers = + getMobileClientHeaders(getAndroidUserAgent(localization)); + + final JsonBuilder builder = prepareJsonBuilder(localization, contentCountry, + innertubeClientRequestInfo, null); + + addVideoIdCpnAndOkChecks(builder, videoId, cpn); + + addPoToken(builder, androidPoTokenResult.playerRequestPoToken); + + final byte[] body = JsonWriter.string(builder.done()) + .getBytes(StandardCharsets.UTF_8); + + final String url = YOUTUBEI_V1_GAPIS_URL + PLAYER + "?" + DISABLE_PRETTY_PRINT_PARAMETER + + "&t=" + generateTParameter() + "&id=" + videoId; + + return JsonUtils.toJsonObject(getValidJsonResponseBody( + getDownloader().postWithContentTypeJson(url, headers, body, localization))); + } + + public static JsonObject getAndroidReelPlayerResponse( + @Nonnull final ContentCountry contentCountry, + @Nonnull final Localization localization, + @Nonnull final String videoId, + @Nonnull final String cpn) throws IOException, ExtractionException { + final InnertubeClientRequestInfo innertubeClientRequestInfo = + InnertubeClientRequestInfo.ofAndroidClient(); + + final Map> headers = + getMobileClientHeaders(getAndroidUserAgent(localization)); + + // We must always pass a valid visitorData to get valid player responses, which needs to be + // got from YouTube + innertubeClientRequestInfo.clientInfo.visitorData = + YoutubeParsingHelper.getVisitorDataFromInnertube(innertubeClientRequestInfo, + localization, contentCountry, headers, YOUTUBEI_V1_GAPIS_URL, null, false); + + final JsonBuilder builder = prepareJsonBuilder(localization, contentCountry, + innertubeClientRequestInfo, null); + + addVideoIdCpnAndOkChecks(builder, videoId, cpn); + + builder.object("playerRequest") + .value(VIDEO_ID, videoId) + .end() + .value("disablePlayerResponse", false); + + final byte[] body = JsonWriter.string(builder.done()) + .getBytes(StandardCharsets.UTF_8); + + final String url = YOUTUBEI_V1_GAPIS_URL + "reel/reel_item_watch" + "?" + + DISABLE_PRETTY_PRINT_PARAMETER + "&t=" + generateTParameter() + "&id=" + videoId + + "&$fields=playerResponse"; + + return JsonUtils.toJsonObject(getValidJsonResponseBody( + getDownloader().postWithContentTypeJson(url, headers, body, localization))) + .getObject("playerResponse"); + } + + public static JsonObject getIosPlayerResponse(@Nonnull final ContentCountry contentCountry, + @Nonnull final Localization localization, + @Nonnull final String videoId, + @Nonnull final String cpn, + @Nullable final PoTokenResult iosPoTokenResult) + throws IOException, ExtractionException { + final InnertubeClientRequestInfo innertubeClientRequestInfo = + InnertubeClientRequestInfo.ofIosClient(); + + final Map> headers = + getMobileClientHeaders(getIosUserAgent(localization)); + + // We must always pass a valid visitorData to get valid player responses, which needs to be + // got from YouTube + innertubeClientRequestInfo.clientInfo.visitorData = iosPoTokenResult == null + ? YoutubeParsingHelper.getVisitorDataFromInnertube(innertubeClientRequestInfo, + localization, contentCountry, headers, YOUTUBEI_V1_URL, null, false) + : iosPoTokenResult.visitorData; + + final JsonBuilder builder = prepareJsonBuilder(localization, contentCountry, + innertubeClientRequestInfo, null); + + addVideoIdCpnAndOkChecks(builder, videoId, cpn); + + if (iosPoTokenResult != null) { + addPoToken(builder, iosPoTokenResult.playerRequestPoToken); + } + + final byte[] body = JsonWriter.string(builder.done()) + .getBytes(StandardCharsets.UTF_8); + + final String url = YOUTUBEI_V1_GAPIS_URL + PLAYER + "?" + DISABLE_PRETTY_PRINT_PARAMETER + + "&t=" + generateTParameter() + "&id=" + videoId; + + return JsonUtils.toJsonObject(getValidJsonResponseBody( + getDownloader().postWithContentTypeJson(url, headers, body, localization))); + } + + private static void addVideoIdCpnAndOkChecks(@Nonnull final JsonBuilder builder, + @Nonnull final String videoId, + @Nullable final String cpn) { + builder.value(VIDEO_ID, videoId); + + if (cpn != null) { + builder.value(CPN, cpn); + } + + builder.value(CONTENT_CHECK_OK, true) + .value(RACY_CHECK_OK, true); + } + + private static void addPlaybackContext(@Nonnull final JsonBuilder builder, + @Nonnull final String referer, + final int signatureTimestamp) { + builder.object("playbackContext") + .object("contentPlaybackContext") + .value("signatureTimestamp", signatureTimestamp) + .value("referer", referer) + .end() + .end(); + } + + private static void addPoToken(@Nonnull final JsonBuilder builder, + @Nonnull final String poToken) { + builder.object(SERVICE_INTEGRITY_DIMENSIONS) + .value(PO_TOKEN, poToken) + .end(); + } + + @Nonnull + private static Map> getMobileClientHeaders( + @Nonnull final String userAgent) { + return Map.of("User-Agent", List.of(userAgent), + "X-Goog-Api-Format-Version", List.of("2")); + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeDashManifestCreatorsUtils.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeDashManifestCreatorsUtils.java index 0b4c41157..f1083b0ab 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeDashManifestCreatorsUtils.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/dashmanifestcreators/YoutubeDashManifestCreatorsUtils.java @@ -1,12 +1,14 @@ package org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getClientInfoHeaders; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getOriginReferrerHeaders; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTvHtml5UserAgent; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl; -import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5StreamingUrl; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebEmbeddedPlayerStreamingUrl; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import org.schabi.newpipe.extractor.MediaFormat; @@ -26,6 +28,7 @@ import org.w3c.dom.Element; import java.io.IOException; import java.io.StringWriter; import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -582,8 +585,8 @@ public final class YoutubeDashManifestCreatorsUtils { * This method fetches, 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} parameters, with a {@code GET} request for streaming URLs from HTML5 - * clients and a {@code POST} request for the ones from the {@code ANDROID} and the + * {@link #RN_0} parameters, with a {@code POS} request for streaming URLs from + * {@code WEB}, {@code TVHTML5}, {@code WEB_EMBEDDED_PLAYER}, {@code ANDROID} and * {@code IOS} clients;
  • *
  • for streaming URLs from HTML5 clients, the {@link #ALR_YES} param is also added. *
  • @@ -601,8 +604,10 @@ public final class YoutubeDashManifestCreatorsUtils { @Nonnull final ItagItem itagItem, final DeliveryType deliveryType) throws CreationException { + final boolean isTvHtml5StreamingUrl = isTvHtml5StreamingUrl(baseStreamingUrl); final boolean isHtml5StreamingUrl = isWebStreamingUrl(baseStreamingUrl) - || isTvHtml5SimplyEmbeddedPlayerStreamingUrl(baseStreamingUrl); + || isTvHtml5StreamingUrl + || isWebEmbeddedPlayerStreamingUrl(baseStreamingUrl); final boolean isAndroidStreamingUrl = isAndroidStreamingUrl(baseStreamingUrl); final boolean isIosStreamingUrl = isIosStreamingUrl(baseStreamingUrl); if (isHtml5StreamingUrl) { @@ -615,7 +620,7 @@ public final class YoutubeDashManifestCreatorsUtils { final String mimeTypeExpected = itagItem.getMediaFormat().getMimeType(); if (!isNullOrEmpty(mimeTypeExpected)) { return getStreamingWebUrlWithoutRedirects(downloader, baseStreamingUrl, - mimeTypeExpected); + mimeTypeExpected, isTvHtml5StreamingUrl); } } else if (isAndroidStreamingUrl || isIosStreamingUrl) { try { @@ -730,6 +735,8 @@ public final class YoutubeDashManifestCreatorsUtils { * @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 isTvHtml5StreamingUrl whether the streaming URL comes from TVHTML5 client, in + * order to use an appropriate HTTP User-Agent header * @param responseMimeTypeExpected the response mime type expected from Google video servers * @return the {@link Response} of the stream, which should have no redirections */ @@ -738,17 +745,23 @@ public final class YoutubeDashManifestCreatorsUtils { private static Response getStreamingWebUrlWithoutRedirects( @Nonnull final Downloader downloader, @Nonnull String streamingUrl, - @Nonnull final String responseMimeTypeExpected) + @Nonnull final String responseMimeTypeExpected, + final boolean isTvHtml5StreamingUrl) throws CreationException { try { - final var headers = getClientInfoHeaders(); + final var headers = new HashMap<>( + getOriginReferrerHeaders("https://www.youtube.com")); + if (isTvHtml5StreamingUrl) { + headers.put("User-Agent", List.of(getTvHtml5UserAgent())); + } String responseMimeType = ""; int redirectsCount = 0; while (!responseMimeType.equals(responseMimeTypeExpected) && redirectsCount < MAXIMUM_REDIRECT_COUNT) { - final Response response = downloader.get(streamingUrl, headers); + final byte[] html5Body = new byte[] {0x78, 0}; + final Response response = downloader.post(streamingUrl, headers, html5Body); final int responseCode = response.responseCode(); if (responseCode != 200) { 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 988a1bcc2..76ef2771b 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 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.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.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,10 @@ import javax.annotation.Nullable; public class YoutubeStreamExtractor extends StreamExtractor { + @Nullable + private static PoTokenProvider poTokenProvider; + private static boolean fetchIosClient; + private JsonObject playerResponse; private JsonObject nextResponse; @@ -112,7 +113,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { @Nullable private JsonObject androidStreamingData; @Nullable - private JsonObject tvHtml5SimplyEmbedStreamingData; + private JsonObject html5StreamingData; private JsonObject videoPrimaryInfoRenderer; private JsonObject videoSecondaryInfoRenderer; @@ -127,7 +128,14 @@ public class YoutubeStreamExtractor extends StreamExtractor { // three different strings are used. private String iosCpn; private String androidCpn; - private String tvHtml5SimplyEmbedCpn; + private String html5Cpn; + + @Nullable + private String html5StreamingUrlsPoToken; + @Nullable + private String androidStreamingUrlsPoToken; + @Nullable + private String iosStreamingUrlsPoToken; public YoutubeStreamExtractor(final StreamingService service, final LinkHandler linkHandler) { super(service, linkHandler); @@ -321,7 +329,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { return Long.parseLong(duration); } catch (final Exception e) { return getDurationFromFirstAdaptiveFormat(Arrays.asList( - iosStreamingData, androidStreamingData, tvHtml5SimplyEmbedStreamingData)); + html5StreamingData, androidStreamingData, iosStreamingData)); } } @@ -579,11 +587,15 @@ public class YoutubeStreamExtractor extends StreamExtractor { public String getDashMpdUrl() throws ParsingException { assertPageFetched(); - // There is no DASH manifest available in the iOS clients and the DASH manifest of the - // Android client doesn't contain all available streams (mainly the WEBM ones) + // There is no DASH manifest available with the iOS client return getManifestUrl( "dash", - Arrays.asList(androidStreamingData, tvHtml5SimplyEmbedStreamingData)); + Arrays.asList( + new Pair<>(androidStreamingData, androidStreamingUrlsPoToken), + new Pair<>(html5StreamingData, html5StreamingUrlsPoToken)), + // Return version 7 of the DASH manifest, which is the latest one, reducing + // manifest size and allowing playback with some DASH players + "mpd_version=7"); } @Nonnull @@ -592,25 +604,44 @@ public class YoutubeStreamExtractor extends StreamExtractor { assertPageFetched(); // Return HLS manifest of the iOS client first because on livestreams, the HLS manifest - // returned has separated audio and video streams + // returned has separated audio and video streams and poTokens requirement do not seem to + // impact HLS formats (if a poToken is provided, it is added) // Also, on videos, non-iOS clients don't have an HLS manifest URL in their player response + // unless a Safari macOS user agent is used return getManifestUrl( "hls", Arrays.asList( - iosStreamingData, androidStreamingData, tvHtml5SimplyEmbedStreamingData)); + new Pair<>(iosStreamingData, iosStreamingUrlsPoToken), + new Pair<>(androidStreamingData, androidStreamingUrlsPoToken), + new Pair<>(html5StreamingData, html5StreamingUrlsPoToken)), + ""); } @Nonnull - private static String getManifestUrl(@Nonnull final String manifestType, - @Nonnull final List streamingDataObjects) { + private static String getManifestUrl( + @Nonnull final String manifestType, + @Nonnull final List> streamingDataObjects, + @Nonnull final String partToAppendToManifestUrlEnd) { final String manifestKey = manifestType + "ManifestUrl"; - return streamingDataObjects.stream() - .filter(Objects::nonNull) - .map(streamingDataObject -> streamingDataObject.getString(manifestKey)) - .filter(Objects::nonNull) - .findFirst() - .orElse(""); + for (final Pair streamingDataObj : streamingDataObjects) { + if (streamingDataObj.getFirst() != null) { + final String manifestUrl = streamingDataObj.getFirst().getString(manifestKey); + if (isNullOrEmpty(manifestUrl)) { + continue; + } + + // If poToken is not null, add it to manifest URL + if (streamingDataObj.getSecond() == null) { + return manifestUrl + "?" + partToAppendToManifestUrlEnd; + } else { + return manifestUrl + "?pot=" + streamingDataObj.getSecond() + "&" + + partToAppendToManifestUrlEnd; + } + } + } + + return ""; } @Override @@ -684,7 +715,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { } private void setStreamType() { - if (playerResponse.getObject("playabilityStatus").has("liveStreamability")) { + if (playerResponse.getObject(PLAYABILITY_STATUS).has("liveStreamability")) { streamType = StreamType.LIVE_STREAM; } else if (playerResponse.getObject("videoDetails").getBoolean("isPostLiveDvr", false)) { streamType = StreamType.POST_LIVE_STREAM; @@ -751,7 +782,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { @Override public String getErrorMessage() { try { - return getTextFromObject(playerResponse.getObject("playabilityStatus") + return getTextFromObject(playerResponse.getObject(PLAYABILITY_STATUS) .getObject("errorScreen").getObject("playerErrorMessageRenderer") .getObject("reason")); } catch (final NullPointerException e) { @@ -766,10 +797,13 @@ public class YoutubeStreamExtractor extends StreamExtractor { 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"; + private static final String PLAYER_CAPTIONS_TRACKLIST_RENDERER + = "playerCaptionsTracklistRenderer"; + private static final String CAPTIONS = "captions"; + private static final String PLAYABILITY_STATUS = "playabilityStatus"; @Override public void onFetchPage(@Nonnull final Downloader downloader) @@ -779,98 +813,53 @@ public class YoutubeStreamExtractor extends StreamExtractor { final Localization localization = getExtractorLocalization(); final ContentCountry contentCountry = getExtractorContentCountry(); - final JsonObject webPlayerResponse = YoutubeParsingHelper.getWebPlayerResponse( - localization, contentCountry, videoId); + final PoTokenProvider poTokenproviderInstance = poTokenProvider; + final boolean noPoTokenProviderSet = poTokenproviderInstance == 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"); + fetchHtml5Client(localization, contentCountry, videoId, poTokenproviderInstance, + noPoTokenProviderSet); setStreamType(); - if (isAgeRestricted) { - fetchTvHtml5EmbedJsonPlayer(contentCountry, localization, videoId); + final PoTokenResult androidPoTokenResult = noPoTokenProviderSet ? null + : poTokenproviderInstance.getAndroidClientPoToken(videoId); - // 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."); - } + fetchAndroidClient(localization, contentCountry, videoId, androidPoTokenResult); - // Refresh the stream type because the stream type may be not properly known for - // age-restricted videos - setStreamType(); - } else { - checkPlayabilityStatus(webPlayerResponse, playabilityStatus); - - // Fetching successfully the iOS player is mandatory to get streams - fetchIosMobileJsonPlayer(contentCountry, localization, videoId); - - 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 - } + if (fetchIosClient) { + final PoTokenResult iosPoTokenResult = noPoTokenProviderSet ? null + : poTokenproviderInstance.getIosClientPoToken(videoId); + fetchIosClient(localization, contentCountry, videoId, iosPoTokenResult); } - // 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 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(@Nonnull final JsonObject playabilityStatus) throws ParsingException { - String status = playabilityStatus.getString("status"); + final String status = playabilityStatus.getString("status"); if (status == null || status.equalsIgnoreCase("ok")) { return; } - // If status exist, and is not "OK", throw the specific exception based on error message - // or a ContentNotAvailableException with the reason text if it's an unknown reason. - final JsonObject newPlayabilityStatus = - youtubePlayerResponse.getObject("playabilityStatus"); - status = newPlayabilityStatus.getString("status"); - final String reason = newPlayabilityStatus.getString("reason"); + final String reason = playabilityStatus.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 = playabilityStatus.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( + "This age-restricted video cannot be watched anonymously"); } } @@ -879,16 +868,18 @@ public class YoutubeStreamExtractor extends StreamExtractor { if (reason.contains("Music Premium")) { throw new YoutubeMusicPremiumContentException(); } + if (reason.contains("payment")) { throw new PaidContentException("This video is a paid video"); } + if (reason.contains("members-only")) { throw new PaidContentException("This video is only available" + " for members of the channel of this video"); } if (reason.contains("unavailable")) { - final String detailedErrorMessage = getTextFromObject(newPlayabilityStatus + final String detailedErrorMessage = getTextFromObject(playabilityStatus .getObject("errorScreen") .getObject("playerErrorMessageRenderer") .getObject("subreason")); @@ -900,120 +891,205 @@ public class YoutubeStreamExtractor extends StreamExtractor { Objects.requireNonNullElse(detailedErrorMessage, reason)); } } + + if (reason.contains("age-restricted")) { + throw new AgeRestrictedContentException( + "This age-restricted video cannot be watched anonymously"); + } } 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) + private void fetchHtml5Client(@Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nullable final PoTokenProvider poTokenProviderInstance, + final boolean noPoTokenProviderSet) 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); + html5Cpn = generateContentPlaybackNonce(); - final JsonObject androidPlayerResponse = getJsonAndroidPostResponse( - "reel/reel_item_watch", - mobileBody, - localization, - "&t=" + generateTParameter() + "&id=" + videoId + "&$fields=playerResponse"); + // Suppress NPE warning as nullability is already checked before and passed with + // noPoTokenProviderSet + //noinspection DataFlowIssue + final PoTokenResult webPoTokenResult = noPoTokenProviderSet ? null + : poTokenProviderInstance.getWebClientPoToken(videoId); + final JsonObject webPlayerResponse; + if (noPoTokenProviderSet || webPoTokenResult == null) { + webPlayerResponse = YoutubeStreamHelper.getWebMetadataPlayerResponse( + localization, contentCountry, videoId); - final JsonObject playerResponseObject = androidPlayerResponse.getObject("playerResponse"); - if (isPlayerResponseNotValid(playerResponseObject, videoId)) { - return; - } + throwExceptionIfPlayerResponseNotValid(webPlayerResponse, videoId); - final JsonObject streamingData = playerResponseObject.getObject(STREAMING_DATA); - if (!isNullOrEmpty(streamingData)) { - androidStreamingData = streamingData; - if (isNullOrEmpty(playerCaptionsTracklistRenderer)) { - playerCaptionsTracklistRenderer = playerResponseObject.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; + + // 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"); + + final JsonObject playabilityStatus = webPlayerResponse.getObject(PLAYABILITY_STATUS); + + if (isVideoAgeRestricted(playabilityStatus)) { + fetchHtml5EmbedClient(localization, contentCountry, videoId, + noPoTokenProviderSet ? null + : poTokenProviderInstance.getWebEmbedClientPoToken(videoId)); + } else { + checkPlayabilityStatus(playabilityStatus); + + final JsonObject tvHtml5PlayerResponse = + YoutubeStreamHelper.getTvHtml5PlayerResponse( + localization, contentCountry, videoId, html5Cpn, + YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId)); + + if (isPlayerResponseNotValid(tvHtml5PlayerResponse, videoId)) { + throw new ExtractionException("TVHTML5 player response is not valid"); + } + + html5StreamingData = tvHtml5PlayerResponse.getObject(STREAMING_DATA); + playerCaptionsTracklistRenderer = tvHtml5PlayerResponse.getObject(CAPTIONS) + .getObject(PLAYER_CAPTIONS_TRACKLIST_RENDERER); + } + } else { + webPlayerResponse = YoutubeStreamHelper.getWebFullPlayerResponse( + localization, contentCountry, videoId, html5Cpn, webPoTokenResult, + YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId)); + + throwExceptionIfPlayerResponseNotValid(webPlayerResponse, videoId); + + // Save the webPlayerResponse into playerResponse in the case the video cannot be + // played, so some metadata can be retrieved + playerResponse = webPlayerResponse; + + // 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"); + + final JsonObject playabilityStatus = webPlayerResponse.getObject(PLAYABILITY_STATUS); + + if (isVideoAgeRestricted(playabilityStatus)) { + fetchHtml5EmbedClient(localization, contentCountry, videoId, + poTokenProviderInstance.getWebEmbedClientPoToken(videoId)); + } else { + checkPlayabilityStatus(playabilityStatus); + html5StreamingData = webPlayerResponse.getObject(STREAMING_DATA); + playerCaptionsTracklistRenderer = webPlayerResponse.getObject(CAPTIONS) + .getObject(PLAYER_CAPTIONS_TRACKLIST_RENDERER); + html5StreamingUrlsPoToken = webPoTokenResult.streamingDataPoToken; } } } - /** - * 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"); - } - - final JsonObject streamingData = iosPlayerResponse.getObject(STREAMING_DATA); - if (!isNullOrEmpty(streamingData)) { - iosStreamingData = streamingData; - playerCaptionsTracklistRenderer = iosPlayerResponse.getObject("captions") - .getObject("playerCaptionsTracklistRenderer"); + private static void throwExceptionIfPlayerResponseNotValid( + @Nonnull final JsonObject webPlayerResponse, + @Nonnull final String videoId) throws ExtractionException { + 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.getObject(PLAYABILITY_STATUS)); + throw new ExtractionException("WEB player response is not valid"); } } - /** - * 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) + private void fetchHtml5EmbedClient(@Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nullable final PoTokenResult webEmbedPoTokenResult) throws IOException, ExtractionException { - tvHtml5SimplyEmbedCpn = generateContentPlaybackNonce(); + html5Cpn = generateContentPlaybackNonce(); - final JsonObject tvHtml5EmbedPlayerResponse = getJsonPostResponse(PLAYER, - createTvHtml5EmbedPlayerBody(localization, - contentCountry, - videoId, - YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId), - tvHtml5SimplyEmbedCpn), localization); + final JsonObject webEmbeddedPlayerResponse = + YoutubeStreamHelper.getWebEmbeddedPlayerResponse(localization, contentCountry, + videoId, html5Cpn, webEmbedPoTokenResult, + YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId)); - if (isPlayerResponseNotValid(tvHtml5EmbedPlayerResponse, videoId)) { - throw new ExtractionException("TVHTML5 embed player response is not valid"); + // Save the webEmbeddedPlayerResponse into playerResponse in the case the video cannot be + // played, so some metadata can be retrieved + playerResponse = webEmbeddedPlayerResponse; + + // Check if the playability status in the player response, if the age-restriction could not + // be bypassed, an exception will be thrown + checkPlayabilityStatus(webEmbeddedPlayerResponse.getObject(PLAYABILITY_STATUS)); + + if (isPlayerResponseNotValid(webEmbeddedPlayerResponse, videoId)) { + throw new ExtractionException("WEB_EMBEDDED_PLAYER player response is not valid"); } - final JsonObject streamingData = tvHtml5EmbedPlayerResponse.getObject(STREAMING_DATA); - if (!isNullOrEmpty(streamingData)) { - playerResponse = tvHtml5EmbedPlayerResponse; - tvHtml5SimplyEmbedStreamingData = streamingData; - playerCaptionsTracklistRenderer = playerResponse.getObject("captions") - .getObject("playerCaptionsTracklistRenderer"); + html5StreamingData = webEmbeddedPlayerResponse.getObject(STREAMING_DATA); + playerCaptionsTracklistRenderer = webEmbeddedPlayerResponse.getObject(CAPTIONS) + .getObject(PLAYER_CAPTIONS_TRACKLIST_RENDERER); + if (webEmbedPoTokenResult != null) { + html5StreamingUrlsPoToken = webEmbedPoTokenResult.streamingDataPoToken; + } + } + + private void fetchAndroidClient(@Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nullable final PoTokenResult androidPoTokenResult) { + try { + androidCpn = generateContentPlaybackNonce(); + + final JsonObject androidPlayerResponse; + if (androidPoTokenResult == null) { + androidPlayerResponse = YoutubeStreamHelper.getAndroidReelPlayerResponse( + contentCountry, localization, videoId, androidCpn); + } else { + androidPlayerResponse = YoutubeStreamHelper.getAndroidPlayerResponse( + contentCountry, localization, videoId, androidCpn, + androidPoTokenResult); + } + + if (!isPlayerResponseNotValid(androidPlayerResponse, videoId)) { + androidStreamingData = androidPlayerResponse.getObject(STREAMING_DATA); + + if (isNullOrEmpty(playerCaptionsTracklistRenderer)) { + playerCaptionsTracklistRenderer = + androidPlayerResponse.getObject(CAPTIONS) + .getObject(PLAYER_CAPTIONS_TRACKLIST_RENDERER); + } + + if (androidPoTokenResult != null) { + androidStreamingUrlsPoToken = androidPoTokenResult.streamingDataPoToken; + } + } + } catch (final Exception ignored) { + // Ignore exceptions related to ANDROID client fetch or parsing, as it is not + // compulsory to play contents + } + } + + private void fetchIosClient(@Nonnull final Localization localization, + @Nonnull final ContentCountry contentCountry, + @Nonnull final String videoId, + @Nullable final PoTokenResult iosPoTokenResult) { + try { + iosCpn = generateContentPlaybackNonce(); + + final JsonObject iosPlayerResponse = YoutubeStreamHelper.getIosPlayerResponse( + contentCountry, localization, videoId, iosCpn, iosPoTokenResult); + + if (!isPlayerResponseNotValid(iosPlayerResponse, videoId)) { + iosStreamingData = iosPlayerResponse.getObject(STREAMING_DATA); + + if (isNullOrEmpty(playerCaptionsTracklistRenderer)) { + playerCaptionsTracklistRenderer = iosPlayerResponse.getObject(CAPTIONS) + .getObject(PLAYER_CAPTIONS_TRACKLIST_RENDERER); + } + + if (iosPoTokenResult != null) { + iosStreamingUrlsPoToken = iosPoTokenResult.streamingDataPoToken; + } + } + } catch (final Exception ignored) { + // Ignore exceptions related to IOS client fetch or parsing, as it is not + // compulsory to play contents } } @@ -1021,7 +1097,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { * Checks whether a player response is invalid. * *

    - * If YouTube detect that requests come from a third party client, they may replace the real + * If YouTube detects that requests come from a third party client, they may replace the real * player response by another one of a video saying that this content is not available on this * app and to watch it on the latest version of YouTube. This behavior has been observed on the * {@code ANDROID} client, see @@ -1054,6 +1130,13 @@ public class YoutubeStreamExtractor extends StreamExtractor { .getString("videoId")); } + private static boolean isVideoAgeRestricted(@Nonnull final JsonObject playabilityStatus) { + // This is language dependent + return "login_required".equalsIgnoreCase(playabilityStatus.getString("status")) + && playabilityStatus.getString("reason", "") + .contains("age"); + } + /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ @@ -1106,22 +1189,28 @@ public class YoutubeStreamExtractor extends StreamExtractor { java.util.stream.Stream.of( /* - Use the iosStreamingData object first because there is no n param and no - signatureCiphers in streaming URLs of the iOS client + Use the html5StreamingData object first because YouTube should have less + control on HTML5 clients, especially for poTokens - The androidStreamingData is used as second way as it isn't used on livestreams, - it doesn't return all available streams, and the Android client extraction is - more likely to break + The androidStreamingData is used as second way as the Android client extraction + is more likely to break - As age-restricted videos are not common, use tvHtml5SimplyEmbedStreamingData - last, which will be the only one not empty for age-restricted content + As iOS streaming data is affected by poTokens and not passing them should lead + to 403 responses, it should be used in the last resort */ - new Pair<>(iosStreamingData, iosCpn), - new Pair<>(androidStreamingData, androidCpn), - new Pair<>(tvHtml5SimplyEmbedStreamingData, tvHtml5SimplyEmbedCpn) - ) - .flatMap(pair -> getStreamsFromStreamingDataKey(videoId, pair.getFirst(), - streamingDataKey, itagTypeWanted, pair.getSecond())) + new Pair<>(html5StreamingData, + new Pair<>(html5Cpn, html5StreamingUrlsPoToken)), + new Pair<>(androidStreamingData, + new Pair<>(androidCpn, androidStreamingUrlsPoToken)), + new Pair<>(iosStreamingData, + new Pair<>(iosCpn, iosStreamingUrlsPoToken))) + .flatMap(pair -> getStreamsFromStreamingDataKey( + videoId, + pair.getFirst(), + streamingDataKey, + itagTypeWanted, + pair.getSecond().getFirst(), + pair.getSecond().getSecond())) .map(streamBuilderHelper) .forEachOrdered(stream -> { if (!Stream.containSimilarStream(stream, streamList)) { @@ -1146,7 +1235,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { *

      *
    • the {@link ItagItem}'s id of the stream as its id;
    • *
    • {@link ItagInfo#getContent()} and {@link ItagInfo#getIsUrl()} as its content and - * and as the value of {@code isUrl};
    • + * as the value of {@code isUrl}; *
    • the media format returned by the {@link ItagItem} as its media format;
    • *
    • its average bitrate with the value returned by {@link * ItagItem#getAverageBitrate()};
    • @@ -1199,7 +1288,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { *
        *
      • the {@link ItagItem}'s id of the stream as its id;
      • *
      • {@link ItagInfo#getContent()} and {@link ItagInfo#getIsUrl()} as its content and - * and as the value of {@code isUrl};
      • + * as the value of {@code isUrl}; *
      • the media format returned by the {@link ItagItem} as its media format;
      • *
      • whether it is video-only with the {@code areStreamsVideoOnly} parameter
      • *
      • the {@link ItagItem};
      • @@ -1255,7 +1344,8 @@ public class YoutubeStreamExtractor extends StreamExtractor { final JsonObject streamingData, final String streamingDataKey, @Nonnull final ItagItem.ItagType itagTypeWanted, - @Nonnull final String contentPlaybackNonce) { + @Nonnull final String contentPlaybackNonce, + @Nullable final String poToken) { if (streamingData == null || !streamingData.has(streamingDataKey)) { return java.util.stream.Stream.empty(); } @@ -1268,7 +1358,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { final ItagItem itagItem = ItagItem.getItag(formatData.getInt("itag")); if (itagItem.itagType == itagTypeWanted) { return buildAndAddItagInfoToList(videoId, formatData, itagItem, - itagItem.itagType, contentPlaybackNonce); + itagItem.itagType, contentPlaybackNonce, poToken); } } catch (final ExtractionException ignored) { // If the itag is not supported, the n parameter of HTML5 clients cannot be @@ -1284,7 +1374,8 @@ public class YoutubeStreamExtractor extends StreamExtractor { @Nonnull final JsonObject formatData, @Nonnull final ItagItem itagItem, @Nonnull final ItagItem.ItagType itagType, - @Nonnull final String contentPlaybackNonce) throws ExtractionException { + @Nonnull final String contentPlaybackNonce, + @Nullable final String poToken) throws ExtractionException { String streamUrl; if (formatData.has("url")) { streamUrl = formatData.getString("url"); @@ -1298,9 +1389,6 @@ public class YoutubeStreamExtractor extends StreamExtractor { streamUrl = cipher.get("url") + "&" + cipher.get("sp") + "=" + signature; } - // Add the content playback nonce to the stream URL - streamUrl += "&" + CPN + "=" + contentPlaybackNonce; - // Decode the n parameter if it is present // If it cannot be decoded, the stream cannot be used as streaming URLs return HTTP 403 // responses if it has not the right value @@ -1310,6 +1398,14 @@ public class YoutubeStreamExtractor extends StreamExtractor { streamUrl = YoutubeJavaScriptPlayerManager.getUrlWithThrottlingParameterDeobfuscated( videoId, streamUrl); + // Add the content playback nonce to the stream URL + streamUrl += "&" + CPN + "=" + contentPlaybackNonce; + + // Add the poToken, if there is one + if (poToken != null) { + streamUrl += "&pot=" + poToken; + } + final JsonObject initRange = formatData.getObject("initRange"); final JsonObject indexRange = formatData.getObject("indexRange"); final String mimeType = formatData.getString("mimeType", ""); @@ -1587,4 +1683,53 @@ public class YoutubeStreamExtractor extends StreamExtractor { .getObject("results") .getArray("contents")); } + + /** + * Set the {@link PoTokenProvider} instance to be used for fetching {@code poToken}s. + * + *

        + * This method allows setting an implementation of {@link PoTokenProvider} which will be used + * to obtain poTokens required for YouTube player requests and streaming URLs. These tokens + * are used by YouTube to verify the integrity of the user's device or browser and are required + * for playback with several clients. + *

        + * + *

        + * Without a {@link PoTokenProvider}, the extractor makes its best effort to fetch as many + * streams as possible, but without {@code poToken}s, some formats may be not available or + * fetching may be slower due to additional requests done to get streams. + *

        + * + *

        + * Note that any provider change will be only applied on the next {@link #fetchPage()} request. + *

        + * + * @param poTokenProvider the {@link PoTokenProvider} instance to set, which can be null to + * remove a provider already passed + * @see PoTokenProvider + */ + @SuppressWarnings("unused") + public static void setPoTokenProvider(@Nullable final PoTokenProvider poTokenProvider) { + YoutubeStreamExtractor.poTokenProvider = poTokenProvider; + } + + /** + * Set whether to fetch the iOS player responses. + * + *

        + * This method allows fetching the iOS player response, which can be useful in scenarios where + * streams from the iOS player response are needed, especially HLS manifests. + *

        + * + *

        + * Note that at the time of writing, YouTube is rolling out a {@code poToken} requirement on + * this client, formats from HLS manifests do not seem to be affected. + *

        + * + * @param fetchIosClient whether to fetch the iOS client + */ + @SuppressWarnings("unused") + public static void setFetchIosClient(final boolean fetchIosClient) { + YoutubeStreamExtractor.fetchIosClient = fetchIosClient; + } }