mirror of
https://github.com/TeamNewPipe/NewPipeExtractor.git
synced 2025-04-27 23:40:36 +05:30
Merge pull request #1272 from AudricV/yt_clients_changes_and_potokens_support
[YouTube] Refactor player clients, add support for poTokens, extract visitor data from the service and more
This commit is contained in:
commit
fe168aba0b
@ -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.
|
||||
*
|
||||
* <p>
|
||||
* It can be extracted by getting the latest release version of the app on
|
||||
* <a href="https://apps.apple.com/us/app/youtube-watch-listen-stream/id544007664/">the App
|
||||
* Store page of the YouTube app</a>, in the {@code What’s New} section.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* See <a href="https://gist.github.com/adamawolf/3048717">this GitHub Gist</a> for more
|
||||
* information.
|
||||
* </p>
|
||||
*/
|
||||
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).
|
||||
*
|
||||
* <p>
|
||||
* 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
|
||||
* <a href="https://theapplewiki.com/wiki/Firmware/iPhone/18.x#iPhone_15_Pro_Max">
|
||||
* https://theapplewiki.com/wiki/Firmware/iPhone/18.x#iPhone_15_Pro_Max</a>
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* This should be the same of as {@link #IOS_OS_VERSION}.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* It can be extracted by getting the latest release version of the app in an APK repository
|
||||
* such as <a href="https://www.apkmirror.com/apk/google-inc/youtube/">APKMirror</a>.
|
||||
* </p>
|
||||
*/
|
||||
static final String ANDROID_CLIENT_VERSION = "19.28.35";
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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.
|
||||
*
|
||||
* <p>
|
||||
* On some major clients, YouTube requires that the integrity of the device passes some checks to
|
||||
* allow playback.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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)).
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* These tokens may have a role in triggering the sign in requirement.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* If an implementation does not want to return a {@code poToken} for a specific client, it <b>must
|
||||
* return {@code null}</b>.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* <b>Implementations of this interface are expected to be thread-safe, as they may be accessed by
|
||||
* multiple threads.</b>
|
||||
* </p>
|
||||
*/
|
||||
public interface PoTokenProvider {
|
||||
|
||||
/**
|
||||
* Get a {@link PoTokenResult} specific to the desktop website, a.k.a. the WEB InnerTube client.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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}.
|
||||
* </p>
|
||||
*
|
||||
* @return a {@link PoTokenResult} specific to the IOS InnerTube client
|
||||
*/
|
||||
@Nullable
|
||||
PoTokenResult getIosClientPoToken(String videoId);
|
||||
}
|
@ -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.
|
||||
*
|
||||
* <p>
|
||||
* It may be required on some clients such as HTML5 ones and may also differ from the player
|
||||
* request {@code poToken}.
|
||||
* </p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
@ -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.
|
||||
*
|
||||
* <p>
|
||||
* It can be extracted by getting the latest release version of the app in an APK repository
|
||||
* such as <a href="https://www.apkmirror.com/apk/google-inc/youtube/">APKMirror</a>.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* It can be extracted by getting the latest release version of the app on
|
||||
* <a href="https://apps.apple.com/us/app/youtube-watch-listen-stream/id544007664/">the App
|
||||
* Store page of the YouTube app</a>, in the {@code What’s New} section.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>
|
||||
* See <a href="https://gist.github.com/adamawolf/3048717">this GitHub Gist</a> for more
|
||||
* information.
|
||||
* </p>
|
||||
*/
|
||||
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.
|
||||
* <p>
|
||||
* 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
|
||||
* <a href="https://theapplewiki.com/wiki/Firmware/iPhone/18.x#iPhone_15_Pro_Max">
|
||||
* https://theapplewiki.com/wiki/Firmware/iPhone/18.x#iPhone_15_Pro_Max</a>
|
||||
* </p>
|
||||
*
|
||||
* @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<JsonObject> prepareDesktopJsonBuilder(
|
||||
@Nonnull final Localization localization,
|
||||
@Nonnull final ContentCountry contentCountry)
|
||||
throws IOException, ExtractionException {
|
||||
return prepareDesktopJsonBuilder(localization, contentCountry, null);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static JsonBuilder<JsonObject> 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<JsonObject> 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<JsonObject> 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<JsonObject> 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<String, List<String>> 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<String, List<String>> getOriginReferrerHeaders(@Nonnull final String url) {
|
||||
public static Map<String, List<String>> 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<String, List<String>> getClientHeaders(@Nonnull final String name,
|
||||
@Nonnull final String version) {
|
||||
static Map<String, List<String>> 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<String, List<String>> httpHeaders,
|
||||
@Nonnull final String innertubeDomainAndVersionEndpoint,
|
||||
@Nullable final String embedUrl,
|
||||
final boolean useGuideEndpoint) throws IOException, ExtractionException {
|
||||
final JsonBuilder<JsonObject> 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<JsonObject> prepareJsonBuilder(
|
||||
@Nonnull final Localization localization,
|
||||
@Nonnull final ContentCountry contentCountry,
|
||||
@Nonnull final InnertubeClientRequestInfo innertubeClientRequestInfo,
|
||||
@Nullable final String embedUrl) {
|
||||
final JsonBuilder<JsonObject> 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;
|
||||
}
|
||||
}
|
||||
|
@ -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<String, List<String>> 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<JsonObject> 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<String, List<String>> 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<JsonObject> 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<JsonObject> 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<String, List<String>> 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<JsonObject> 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<String, List<String>> headers =
|
||||
getMobileClientHeaders(getAndroidUserAgent(localization));
|
||||
|
||||
final JsonBuilder<JsonObject> 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<String, List<String>> 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<JsonObject> 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<String, List<String>> 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<JsonObject> 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<JsonObject> 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<JsonObject> 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<JsonObject> builder,
|
||||
@Nonnull final String poToken) {
|
||||
builder.object(SERVICE_INTEGRITY_DIMENSIONS)
|
||||
.value(PO_TOKEN, poToken)
|
||||
.end();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private static Map<String, List<String>> getMobileClientHeaders(
|
||||
@Nonnull final String userAgent) {
|
||||
return Map.of("User-Agent", List.of(userAgent),
|
||||
"X-Goog-Api-Format-Version", List.of("2"));
|
||||
}
|
||||
}
|
@ -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:
|
||||
* <ul>
|
||||
* <li>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;</li>
|
||||
* <li>for streaming URLs from HTML5 clients, the {@link #ALR_YES} param is also added.
|
||||
* </li>
|
||||
@ -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) {
|
||||
|
@ -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<JsonObject> streamingDataObjects) {
|
||||
private static String getManifestUrl(
|
||||
@Nonnull final String manifestType,
|
||||
@Nonnull final List<Pair<JsonObject, String>> 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<JsonObject, String> 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.
|
||||
*
|
||||
* <p>
|
||||
* 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 {
|
||||
* <ul>
|
||||
* <li>the {@link ItagItem}'s id of the stream as its id;</li>
|
||||
* <li>{@link ItagInfo#getContent()} and {@link ItagInfo#getIsUrl()} as its content and
|
||||
* and as the value of {@code isUrl};</li>
|
||||
* as the value of {@code isUrl};</li>
|
||||
* <li>the media format returned by the {@link ItagItem} as its media format;</li>
|
||||
* <li>its average bitrate with the value returned by {@link
|
||||
* ItagItem#getAverageBitrate()};</li>
|
||||
@ -1199,7 +1288,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
|
||||
* <ul>
|
||||
* <li>the {@link ItagItem}'s id of the stream as its id;</li>
|
||||
* <li>{@link ItagInfo#getContent()} and {@link ItagInfo#getIsUrl()} as its content and
|
||||
* and as the value of {@code isUrl};</li>
|
||||
* as the value of {@code isUrl};</li>
|
||||
* <li>the media format returned by the {@link ItagItem} as its media format;</li>
|
||||
* <li>whether it is video-only with the {@code areStreamsVideoOnly} parameter</li>
|
||||
* <li>the {@link ItagItem};</li>
|
||||
@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* Note that any provider change will be only applied on the next {@link #fetchPage()} request.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @param fetchIosClient whether to fetch the iOS client
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
public static void setFetchIosClient(final boolean fetchIosClient) {
|
||||
YoutubeStreamExtractor.fetchIosClient = fetchIosClient;
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user