Use the Android mobile API when there are OTF streams or the content is protected by signatureCiphers

Use the Android mobile API to get the itag 22 (720p with audio), removed when the content is protected by signatureCiphers.
Also use this API when they are OTF streams, to get the itag 17 and 36, low 3GPP quality streams but also the itag 139.
Update the web client version.
This commit is contained in:
TiA4f8R 2021-05-29 14:43:26 +02:00
parent e7d589edbf
commit 013b902535
No known key found for this signature in database
GPG Key ID: E6D3E7F5949450DD
2 changed files with 276 additions and 84 deletions

View File

@ -64,8 +64,10 @@ public class YoutubeParsingHelper {
private YoutubeParsingHelper() { private YoutubeParsingHelper() {
} }
private static final String HARDCODED_CLIENT_VERSION = "2.20210520.09.00"; private static final String HARDCODED_CLIENT_VERSION = "2.20210526.07.00";
private static final String HARDCODED_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; private static final String HARDCODED_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
private static final String[] MOBILE_YOUTUBE_KEYS = {"AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w",
"16.20.35"};
private static final String YOUTUBEI_V1_URL = "https://www.youtube.com/youtubei/v1/"; private static final String YOUTUBEI_V1_URL = "https://www.youtube.com/youtubei/v1/";
private static String clientVersion; private static String clientVersion;
private static String key; private static String key;
@ -661,7 +663,7 @@ public class YoutubeParsingHelper {
} }
@Nullable @Nullable
public static String getTextFromObject(JsonObject textObject) throws ParsingException { public static String getTextFromObject(final JsonObject textObject) throws ParsingException {
return getTextFromObject(textObject, false); return getTextFromObject(textObject, false);
} }
@ -744,6 +746,26 @@ public class YoutubeParsingHelper {
return JsonUtils.toJsonObject(getValidJsonResponseBody(response)); return JsonUtils.toJsonObject(getValidJsonResponseBody(response));
} }
public static JsonObject getJsonMobilePostResponse(final String endpoint,
final byte[] body,
final ContentCountry contentCountry,
final Localization localization)
throws IOException, ExtractionException {
final Map<String, List<String>> headers = new HashMap<>();
headers.put("Content-Type", Collections.singletonList("application/json"));
// Spoofing an Android 11 device with the hardcoded version of the Android app
headers.put("User-Agent", Collections.singletonList("com.google.android.youtube/"
+ MOBILE_YOUTUBE_KEYS[1] + "Linux; U; Android 11; "
+ contentCountry.getCountryCode() + ") gzip"));
headers.put("x-goog-api-format-version", Collections.singletonList("2"));
final Response response = getDownloader().post(
"https://youtubei.googleapis.com/youtubei/v1/" + endpoint + "?key="
+ MOBILE_YOUTUBE_KEYS[0], headers, body, localization);
return JsonUtils.toJsonObject(getValidJsonResponseBody(response));
}
public static JsonArray getJsonResponse(final String url, final Localization localization) public static JsonArray getJsonResponse(final String url, final Localization localization)
throws IOException, ExtractionException { throws IOException, ExtractionException {
Map<String, List<String>> headers = new HashMap<>(); Map<String, List<String>> headers = new HashMap<>();
@ -771,7 +793,7 @@ public class YoutubeParsingHelper {
return JsonObject.builder() return JsonObject.builder()
.object("context") .object("context")
.object("client") .object("client")
.value("clientName", "1") .value("clientName", "WEB")
.value("clientVersion", getClientVersion()) .value("clientVersion", getClientVersion())
.value("hl", localization.getLocalizationCode()) .value("hl", localization.getLocalizationCode())
.value("gl", contentCountry.getCountryCode()) .value("gl", contentCountry.getCountryCode())
@ -780,6 +802,23 @@ public class YoutubeParsingHelper {
// @formatter:on // @formatter:on
} }
public static JsonBuilder<JsonObject> prepareMobileJsonBuilder(final Localization localization,
final ContentCountry
contentCountry)
throws IOException, ExtractionException {
// @formatter:off
return JsonObject.builder()
.object("context")
.object("client")
.value("clientName", "ANDROID")
.value("clientVersion", MOBILE_YOUTUBE_KEYS[1])
.value("hl", localization.getLocalizationCode())
.value("gl", contentCountry.getCountryCode())
.end()
.end();
// @formatter:on
}
/** /**
* Add required headers and cookies to an existing headers Map. * Add required headers and cookies to an existing headers Map.
* @see #addClientInfoHeaders(Map) * @see #addClientInfoHeaders(Map)

View File

@ -94,6 +94,10 @@ public class YoutubeStreamExtractor extends StreamExtractor {
private static String cachedDeobfuscationCode = null; private static String cachedDeobfuscationCode = null;
@Nullable @Nullable
private String playerJsUrl = null; private String playerJsUrl = null;
@Nullable
private String sts = null;
@Nullable
private String playerCode = null;
@Nonnull @Nonnull
private final Map<String, String> videoInfoPage = new HashMap<>(); private final Map<String, String> videoInfoPage = new HashMap<>();
@ -101,6 +105,9 @@ public class YoutubeStreamExtractor extends StreamExtractor {
private JsonObject initialData; private JsonObject initialData;
private JsonObject playerResponse; private JsonObject playerResponse;
private JsonObject nextResponse; private JsonObject nextResponse;
@Nullable
private JsonObject streamingData;
private JsonObject videoPrimaryInfoRenderer; private JsonObject videoPrimaryInfoRenderer;
private JsonObject videoSecondaryInfoRenderer; private JsonObject videoSecondaryInfoRenderer;
private int ageLimit = -1; private int ageLimit = -1;
@ -165,9 +172,9 @@ public class YoutubeStreamExtractor extends StreamExtractor {
.substring(10); .substring(10);
try { // Premiered 20 hours ago try { // Premiered 20 hours ago
TimeAgoParser timeAgoParser = TimeAgoPatternsManager.getTimeAgoParserFor( final TimeAgoParser timeAgoParser = TimeAgoPatternsManager.getTimeAgoParserFor(
Localization.fromLocalizationCode("en")); Localization.fromLocalizationCode("en"));
OffsetDateTime parsedTime = timeAgoParser.parse(time).offsetDateTime(); final OffsetDateTime parsedTime = timeAgoParser.parse(time).offsetDateTime();
return DateTimeFormatter.ISO_LOCAL_DATE.format(parsedTime); return DateTimeFormatter.ISO_LOCAL_DATE.format(parsedTime);
} catch (final Exception ignored) { } catch (final Exception ignored) {
} }
@ -183,8 +190,8 @@ public class YoutubeStreamExtractor extends StreamExtractor {
try { try {
// TODO: this parses English formatted dates only, we need a better approach to parse // TODO: this parses English formatted dates only, we need a better approach to parse
// the textual date // the textual date
LocalDate localDate = LocalDate.parse(getTextFromObject(getVideoPrimaryInfoRenderer() final LocalDate localDate = LocalDate.parse(getTextFromObject(
.getObject("dateText")), getVideoPrimaryInfoRenderer().getObject("dateText")),
DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH)); DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH));
return DateTimeFormatter.ISO_LOCAL_DATE.format(localDate); return DateTimeFormatter.ISO_LOCAL_DATE.format(localDate);
} catch (final Exception ignored) { } catch (final Exception ignored) {
@ -225,13 +232,13 @@ public class YoutubeStreamExtractor extends StreamExtractor {
@Override @Override
public Description getDescription() throws ParsingException { public Description getDescription() throws ParsingException {
assertPageFetched(); assertPageFetched();
// description with more info on links // Description with more info on links
try { try {
String description = getTextFromObject(getVideoSecondaryInfoRenderer() String description = getTextFromObject(getVideoSecondaryInfoRenderer()
.getObject("description"), true); .getObject("description"), true);
if (!isNullOrEmpty(description)) return new Description(description, Description.HTML); if (!isNullOrEmpty(description)) return new Description(description, Description.HTML);
} catch (final ParsingException ignored) { } catch (final ParsingException ignored) {
// age-restricted videos cause a ParsingException here // Age-restricted videos cause a ParsingException here
} }
String description = playerResponse.getObject("videoDetails") String description = playerResponse.getObject("videoDetails")
@ -242,7 +249,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
description = getTextFromObject(descriptionObject); description = getTextFromObject(descriptionObject);
} }
// raw non-html description // Raw non-html description
return new Description(description, Description.PLAIN_TEXT); return new Description(description, Description.PLAIN_TEXT);
} }
@ -277,16 +284,14 @@ public class YoutubeStreamExtractor extends StreamExtractor {
assertPageFetched(); assertPageFetched();
try { try {
String duration = playerResponse final String duration = playerResponse
.getObject("videoDetails") .getObject("videoDetails")
.getString("lengthSeconds"); .getString("lengthSeconds");
return Long.parseLong(duration); return Long.parseLong(duration);
} catch (final Exception e) { } catch (final Exception e) {
try { try {
String durationMs = playerResponse final JsonArray formats = streamingData.getArray("formats");
.getObject("streamingData") final String durationMs = formats.getObject(formats.size() - 1)
.getArray("formats")
.getObject(0)
.getString("approxDurationMs"); .getString("approxDurationMs");
return Math.round(Long.parseLong(durationMs) / 1000f); return Math.round(Long.parseLong(durationMs) / 1000f);
} catch (final Exception ignored) { } catch (final Exception ignored) {
@ -306,7 +311,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
getTimestampSeconds("((#|&|\\?)t=\\d{0,3}h?\\d{0,3}m?\\d{1,3}s?)"); getTimestampSeconds("((#|&|\\?)t=\\d{0,3}h?\\d{0,3}m?\\d{1,3}s?)");
if (timestamp == -2) { if (timestamp == -2) {
// regex for timestamp was not found // Regex for timestamp was not found
return 0; return 0;
} else { } else {
return timestamp; return timestamp;
@ -331,7 +336,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
likesString = getVideoPrimaryInfoRenderer().getObject("sentimentBar") likesString = getVideoPrimaryInfoRenderer().getObject("sentimentBar")
.getObject("sentimentBarRenderer").getString("tooltip").split("/")[0]; .getObject("sentimentBarRenderer").getString("tooltip").split("/")[0];
} catch (final NullPointerException e) { } catch (final NullPointerException e) {
// if this kicks in our button has no content and therefore ratings must be disabled // If this kicks in our button has no content and therefore ratings must be disabled
if (playerResponse.getObject("videoDetails").getBoolean("allowRatings")) { if (playerResponse.getObject("videoDetails").getBoolean("allowRatings")) {
throw new ParsingException( throw new ParsingException(
"Ratings are enabled even though the like button is missing", e); "Ratings are enabled even though the like button is missing", e);
@ -360,7 +365,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
dislikesString = getVideoPrimaryInfoRenderer().getObject("sentimentBar") dislikesString = getVideoPrimaryInfoRenderer().getObject("sentimentBar")
.getObject("sentimentBarRenderer").getString("tooltip").split("/")[1]; .getObject("sentimentBarRenderer").getString("tooltip").split("/")[1];
} catch (final NullPointerException e) { } catch (final NullPointerException e) {
// if this kicks in our button has no content and therefore ratings must be disabled // If this kicks in our button has no content and therefore ratings must be disabled
if (playerResponse.getObject("videoDetails").getBoolean("allowRatings")) { if (playerResponse.getObject("videoDetails").getBoolean("allowRatings")) {
throw new ParsingException( throw new ParsingException(
"Ratings are enabled even though the dislike button is missing", e); "Ratings are enabled even though the dislike button is missing", e);
@ -384,6 +389,9 @@ public class YoutubeStreamExtractor extends StreamExtractor {
public String getUploaderUrl() throws ParsingException { public String getUploaderUrl() throws ParsingException {
assertPageFetched(); assertPageFetched();
// Don't use the id in the videoSecondaryRenderer object to get real id of the uploader
// The difference between the real id of the channel and the displayed id is especially
// visible for music channels and autogenerated channels.
final String uploaderId = playerResponse.getObject("videoDetails").getString("channelId"); final String uploaderId = playerResponse.getObject("videoDetails").getString("channelId");
if (!isNullOrEmpty(uploaderId)) { if (!isNullOrEmpty(uploaderId)) {
return YoutubeChannelLinkHandlerFactory.getInstance().getUrl("channel/" + uploaderId); return YoutubeChannelLinkHandlerFactory.getInstance().getUrl("channel/" + uploaderId);
@ -426,7 +434,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
.getObject("videoOwnerRenderer").getObject("thumbnail") .getObject("videoOwnerRenderer").getObject("thumbnail")
.getArray("thumbnails").getObject(0).getString("url"); .getArray("thumbnails").getObject(0).getString("url");
} catch (final ParsingException ignored) { } catch (final ParsingException ignored) {
// age-restricted videos cause a ParsingException here // Age-restricted videos cause a ParsingException here
} }
if (isNullOrEmpty(url)) { if (isNullOrEmpty(url)) {
@ -463,8 +471,8 @@ public class YoutubeStreamExtractor extends StreamExtractor {
assertPageFetched(); assertPageFetched();
try { try {
String dashManifestUrl; String dashManifestUrl;
if (playerResponse.getObject("streamingData").isString("dashManifestUrl")) { if (streamingData.isString("dashManifestUrl")) {
return playerResponse.getObject("streamingData").getString("dashManifestUrl"); return streamingData.getString("dashManifestUrl");
} else if (videoInfoPage.containsKey("dashmpd")) { } else if (videoInfoPage.containsKey("dashmpd")) {
dashManifestUrl = videoInfoPage.get("dashmpd"); dashManifestUrl = videoInfoPage.get("dashmpd");
} else { } else {
@ -493,7 +501,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
assertPageFetched(); assertPageFetched();
try { try {
return playerResponse.getObject("streamingData").getString("hlsManifestUrl"); return streamingData.getString("hlsManifestUrl");
} catch (final Exception e) { } catch (final Exception e) {
throw new ParsingException("Could not get hls manifest url", e); throw new ParsingException("Could not get hls manifest url", e);
} }
@ -584,12 +592,12 @@ public class YoutubeStreamExtractor extends StreamExtractor {
@Nonnull @Nonnull
public List<SubtitlesStream> getSubtitles(final MediaFormat format) throws ParsingException { public List<SubtitlesStream> getSubtitles(final MediaFormat format) throws ParsingException {
assertPageFetched(); assertPageFetched();
// if the video is age restricted getPlayerConfig will fail // If the video is age restricted getSubtitles will fail
if (getAgeLimit() != NO_AGE_LIMIT) { if (getAgeLimit() != NO_AGE_LIMIT) {
return Collections.emptyList(); return Collections.emptyList();
} }
if (subtitles != null) { if (subtitles != null) {
// already calculated // Already calculated
return subtitles; return subtitles;
} }
@ -622,8 +630,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
@Override @Override
public StreamType getStreamType() { public StreamType getStreamType() {
assertPageFetched(); assertPageFetched();
return playerResponse.getObject("streamingData").has(FORMATS) return streamingData.has(FORMATS) ? StreamType.VIDEO_STREAM : StreamType.LIVE_STREAM;
? StreamType.VIDEO_STREAM : StreamType.LIVE_STREAM;
} }
@Nullable @Nullable
@ -667,7 +674,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
.getObject("errorScreen").getObject("playerErrorMessageRenderer") .getObject("errorScreen").getObject("playerErrorMessageRenderer")
.getObject("reason")); .getObject("reason"));
} catch (final ParsingException | NullPointerException e) { } catch (final ParsingException | NullPointerException e) {
return null; // no error message return null; // No error message
} }
} }
@ -680,36 +687,48 @@ public class YoutubeStreamExtractor extends StreamExtractor {
private static final String HTTPS = "https:"; private static final String HTTPS = "https:";
private static final String DEOBFUSCATION_FUNC_NAME = "deobfuscate"; private static final String DEOBFUSCATION_FUNC_NAME = "deobfuscate";
private final static String[] REGEXES = { private static final String[] REGEXES = {
"(?:\\b|[^a-zA-Z0-9$])([a-zA-Z0-9$]{2})\\s*=\\s*function\\(\\s*a\\s*\\)\\s*\\{\\s*a\\s*=\\s*a\\.split\\(\\s*\"\"\\s*\\)", "(?:\\b|[^a-zA-Z0-9$])([a-zA-Z0-9$]{2})\\s*=\\s*function\\(\\s*a\\s*\\)\\s*\\{\\s*a\\s*=\\s*a\\.split\\(\\s*\"\"\\s*\\)",
"([\\w$]+)\\s*=\\s*function\\((\\w+)\\)\\{\\s*\\2=\\s*\\2\\.split\\(\"\"\\)\\s*;", "([\\w$]+)\\s*=\\s*function\\((\\w+)\\)\\{\\s*\\2=\\s*\\2\\.split\\(\"\"\\)\\s*;",
"\\b([\\w$]{2})\\s*=\\s*function\\((\\w+)\\)\\{\\s*\\2=\\s*\\2\\.split\\(\"\"\\)\\s*;", "\\b([\\w$]{2})\\s*=\\s*function\\((\\w+)\\)\\{\\s*\\2=\\s*\\2\\.split\\(\"\"\\)\\s*;",
"yt\\.akamaized\\.net/\\)\\s*\\|\\|\\s*.*?\\s*c\\s*&&\\s*d\\.set\\([^,]+\\s*,\\s*(:encodeURIComponent\\s*\\()([a-zA-Z0-9$]+)\\(",
"\\bc\\s*&&\\s*d\\.set\\([^,]+\\s*,\\s*(:encodeURIComponent\\s*\\()([a-zA-Z0-9$]+)\\(" "\\bc\\s*&&\\s*d\\.set\\([^,]+\\s*,\\s*(:encodeURIComponent\\s*\\()([a-zA-Z0-9$]+)\\("
}; };
private static final String STS_REGEX = "signatureTimestamp[=:](\\d+)";
@Override @Override
public void onFetchPage(@Nonnull final Downloader downloader) public void onFetchPage(@Nonnull final Downloader downloader)
throws IOException, ExtractionException { throws IOException, ExtractionException {
final String videoId = super.getId(); final String videoId = getId();
final Localization localization = getExtractorLocalization(); final Localization localization = getExtractorLocalization();
final ContentCountry contentCountry = getExtractorContentCountry(); final ContentCountry contentCountry = getExtractorContentCountry();
final byte[] body = JsonWriter.string(prepareJsonBuilder(localization, final byte[] body = JsonWriter.string(prepareJsonBuilder(localization, contentCountry)
contentCountry)
.value("videoId", videoId) .value("videoId", videoId)
.done()) .done())
.getBytes(UTF_8); .getBytes(UTF_8);
playerResponse = getJsonPostResponse("player", body, localization);
// Save the playerResponse from the youtube.com website, // This boolean is needed if we don't want to fetch again the JSON player if the sts string
// because there can be restrictions on the embedded player. // is not null.
// E.g. if a video is age-restricted, the embedded player's playabilityStatus says, boolean stsKnown = false;
// that the video cannot be played outside of YouTube,
// but does not show the original message. // Put the sts string if we already know it so we don't have to fetch again the player
// endpoint of the desktop internal API if something went wrong when parsing the Android
// API.
if (sts != null) {
playerResponse = getJsonPostResponse("player", createPlayerBodyWithSts(localization,
contentCountry, videoId), localization);
stsKnown = true;
} else {
playerResponse = getJsonPostResponse("player", body, localization);
}
// Save the playerResponse from the player endpoint of the desktop internal API because
// there can be restrictions on 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.
JsonObject youtubePlayerResponse = playerResponse; JsonObject youtubePlayerResponse = playerResponse;
if (playerResponse == null || !playerResponse.has("streamingData")) { if (playerResponse == null || !playerResponse.has("streamingData")) {
// try to get player response by fetching video info page // Try to get the player response by fetching video info page
fetchVideoInfoPage(); fetchVideoInfoPage();
} }
@ -719,26 +738,38 @@ public class YoutubeStreamExtractor extends StreamExtractor {
youtubePlayerResponse = playerResponse; youtubePlayerResponse = playerResponse;
} }
JsonObject playabilityStatus = (playerResponse == null ? youtubePlayerResponse final JsonObject playabilityStatus = (playerResponse == null ? youtubePlayerResponse
: playerResponse) : playerResponse).getObject("playabilityStatus");
.getObject("playabilityStatus");
checkPlayabilityStatus(youtubePlayerResponse, playabilityStatus);
nextResponse = getJsonPostResponse("next", body, localization);
streamingData = playerResponse.getObject("streamingData");
if (hasOtfStreams() || isCipherProtectedContent()) {
fetchAndroidMobileJsonPlayer(contentCountry, localization, videoId, stsKnown);
}
}
private void checkPlayabilityStatus(final JsonObject youtubePlayerResponse,
JsonObject playabilityStatus) throws ParsingException {
String status = playabilityStatus.getString("status"); String status = playabilityStatus.getString("status");
// If status exist, and is not "OK", throw the specific exception based on error message // 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. // or a ContentNotAvailableException with the reason text if it's an unknown reason.
if (status != null && !status.equalsIgnoreCase("ok")) { if (status != null && !status.equalsIgnoreCase("ok")) {
playabilityStatus = youtubePlayerResponse.getObject("playabilityStatus"); playabilityStatus = youtubePlayerResponse.getObject("playabilityStatus");
status = playabilityStatus.getString("status"); status = playabilityStatus.getString("status");
final String reason = playabilityStatus.getString("reason"); final String reason = playabilityStatus.getString("reason");
if (status.equalsIgnoreCase("login_required")) { if (status.equalsIgnoreCase("login_required")) {
if (reason == null) { if (reason == null) {
final String message = playabilityStatus.getArray("messages").getString(0); final String message = playabilityStatus.getArray("messages").getString(0);
if (message != null && message.equals("This is a private video. Please sign in to verify that you may see it.")) { if (message != null && message.equals(
"This is a private video. Please sign in to verify that you may see it.")) {
throw new PrivateContentException("This video is private."); throw new PrivateContentException("This video is private.");
} }
} else if (reason.equals("Sign in to confirm your age")) { } else if (reason.equals("Sign in to confirm your age")) {
// No streams can be fetched, therefore thrown an AgeRestrictedContentException // No streams can be fetched, therefore throw an AgeRestrictedContentException
// explicitly. // explicitly.
throw new AgeRestrictedContentException( throw new AgeRestrictedContentException(
"This age-restricted video cannot be watched."); "This age-restricted video cannot be watched.");
@ -752,20 +783,17 @@ public class YoutubeStreamExtractor extends StreamExtractor {
if (reason.equals("This video requires payment to watch.")) { if (reason.equals("This video requires payment to watch.")) {
throw new PaidContentException("This video is a paid video"); throw new PaidContentException("This video is a paid video");
} }
if (reason.equals("Join this channel to get access to members-only content like this video, and other exclusive perks.") || if (reason.equals("Join this channel to get access to members-only content like this video, and other exclusive perks.")
reason.equals("Join this channel to get access to members-only content like this video and other exclusive perks.")) { || reason.equals("Join this channel to get access to members-only content like this video and other exclusive perks.")) {
throw new PaidContentException("This video is only available for members of the channel of this video"); throw new PaidContentException("This video is only available for members of the channel of this video");
} }
if (reason.equals("Video unavailable")) { if (reason.equals("Video unavailable")) {
final String detailedErrorMessage = playabilityStatus final String detailedErrorMessage = getTextFromObject(playabilityStatus
.getObject("errorScreen") .getObject("errorScreen").getObject("playerErrorMessageRenderer")
.getObject("playerErrorMessageRenderer") .getObject("subreason"));
.getObject("subreason")
.getArray("runs")
.getObject(0)
.getString("text");
if (detailedErrorMessage != null) { if (detailedErrorMessage != null) {
if (detailedErrorMessage.equals("The uploader has not made this video available in your country.")) { if (detailedErrorMessage.equals(
"The uploader has not made this video available in your country.")) {
throw new GeographicRestrictionException( throw new GeographicRestrictionException(
"This video is not available in user's country."); "This video is not available in user's country.");
} }
@ -776,11 +804,54 @@ public class YoutubeStreamExtractor extends StreamExtractor {
throw new ContentNotAvailableException("Got error: \"" + reason + "\""); throw new ContentNotAvailableException("Got error: \"" + reason + "\"");
} }
nextResponse = getJsonPostResponse("next", body, localization); }
private void fetchAndroidMobileJsonPlayer(final ContentCountry contentCountry,
final Localization localization,
final String videoId,
final boolean stsKnown) throws ExtractionException,
IOException {
JsonObject mobilePlayerResponse = null;
final byte[] mobileBody = JsonWriter.string(prepareMobileJsonBuilder(localization,
contentCountry)
.value("videoId", videoId)
.done())
.getBytes(UTF_8);
try {
mobilePlayerResponse = getJsonMobilePostResponse("player", mobileBody,
contentCountry, localization);
} catch (final IOException | ExtractionException ignored) {
}
if (mobilePlayerResponse != null && mobilePlayerResponse.has("streamingData")) {
final JsonObject mobileStreamingData = mobilePlayerResponse.getObject(
"streamingData");
if (!isNullOrEmpty(mobileStreamingData)) streamingData = mobileStreamingData;
} else {
// Fallback to the desktop JSON player endpoint
if (sts == null) {
sts = getStsFromPlayerJs();
}
// The cipher signatures from the player endpoint without a timestamp are invalid so
// download it again only if we didn't have a signatureTimestamp before fetching the
// data of this video (the sts string).
if (!stsKnown) {
final JsonObject playerResponseWithSignatureTimestamp = getJsonPostResponse(
"player", createPlayerBodyWithSts(localization, contentCountry, videoId),
localization);
if (playerResponseWithSignatureTimestamp.has("streamingData")) {
streamingData = playerResponseWithSignatureTimestamp.getObject(
"streamingData");
}
}
}
} }
private void fetchVideoInfoPage() throws ParsingException, ReCaptchaException, IOException { private void fetchVideoInfoPage() throws ParsingException, ReCaptchaException, IOException {
final String sts = getEmbeddedInfoStsAndStorePlayerJsUrl(); if (sts == null) {
sts = getStsFromPlayerJs();
}
final String videoInfoUrl = getVideoInfoUrl(getId(), sts); final String videoInfoUrl = getVideoInfoUrl(getId(), sts);
final String infoPageResponse = NewPipe.getDownloader() final String infoPageResponse = NewPipe.getDownloader()
.get(videoInfoUrl, getExtractorLocalization()).responseBody(); .get(videoInfoUrl, getExtractorLocalization()).responseBody();
@ -794,21 +865,40 @@ public class YoutubeStreamExtractor extends StreamExtractor {
} }
} }
@Nonnull private byte[] createPlayerBodyWithSts(final Localization localization,
private String getEmbeddedInfoStsAndStorePlayerJsUrl() { final ContentCountry contentCountry,
final String videoId) throws ExtractionException,
IOException {
// @formatter:off
return JsonWriter.string(prepareJsonBuilder(localization,
contentCountry)
.value("videoId", videoId)
.object("playbackContext")
.object("contentPlaybackContext")
.value("signatureTimestamp", sts)
.end()
.end()
.done())
.getBytes(UTF_8);
// @formatter:on
}
private void storePlayerJs() throws ParsingException {
try { try {
// Don't provide a video id to get a smaller response (around 9kb instead of 21 kb) // The JavaScript player was not found in any page fetched so far and there is
// nothing cached, so try fetching embedded info.
// Don't provide a video id to get a smaller response (around 9kb instead of 21 kb
// with a video)
final String embedUrl = "https://www.youtube.com/embed/"; final String embedUrl = "https://www.youtube.com/embed/";
final String embedPageContent = NewPipe.getDownloader() final String embedPageContent = NewPipe.getDownloader()
.get(embedUrl, getExtractorLocalization()).responseBody(); .get(embedUrl, getExtractorLocalization()).responseBody();
try { try {
final String assetsPattern = "\"assets\":.+?\"js\":\\s*(\"[^\"]+\")"; final String assetsPattern = "\"assets\":.+?\"js\":\\s*(\"[^\"]+\")";
playerJsUrl = Parser.matchGroup1(assetsPattern, embedPageContent) playerJsUrl = Parser.matchGroup1(assetsPattern, embedPageContent)
.replace("\\", "").replace("\"", ""); .replace("\\", "").replace("\"", "");
} catch (final Parser.RegexException ex) { } catch (final Parser.RegexException ex) {
// playerJsUrl is still available in the file, just somewhere else TODO // playerJsUrl is still available in the file, just somewhere else TODO
// it is ok not to find it, see how that's handled in getDeobfuscationCode() // It is ok not to find it, see how that's handled in getDeobfuscationCode()
final Document doc = Jsoup.parse(embedPageContent); final Document doc = Jsoup.parse(embedPageContent);
final Elements elems = doc.select("script").attr("name", "player_ias/base"); final Elements elems = doc.select("script").attr("name", "player_ias/base");
for (final Element elem : elems) { for (final Element elem : elems) {
@ -819,14 +909,64 @@ public class YoutubeStreamExtractor extends StreamExtractor {
} }
} }
// Get embed sts if (playerJsUrl != null) {
return Parser.matchGroup1("\"sts\"\\s*:\\s*(\\d+)", embedPageContent); if (playerJsUrl.startsWith("//")) {
} catch (final Exception i) { playerJsUrl = HTTPS + playerJsUrl;
// if it fails we simply reply with no sts as then it does not seem to be necessary } else if (playerJsUrl.startsWith("/")) {
return ""; // Sometimes https://www.youtube.com part has to be added manually
playerJsUrl = HTTPS + "//www.youtube.com" + playerJsUrl;
}
playerCode = NewPipe.getDownloader().get(playerJsUrl, getExtractorLocalization())
.responseBody();
} else {
throw new ExtractionException("Could not extract JS player URL");
}
} catch (final Exception e) {
throw new ParsingException("Could not store JavaScript player", e);
} }
} }
private boolean hasOtfStreams() {
if (streamingData != null) {
boolean hasOtfStreamsValue = false;
if (streamingData.has("adaptiveFormats")) {
final JsonArray adaptiveFormats = streamingData.getArray("adaptiveFormats");
for (final Object adaptiveFormat : adaptiveFormats) {
final JsonObject jsonAdaptiveFormat = (JsonObject) adaptiveFormat;
if (jsonAdaptiveFormat.has("type")) {
final String streamTypeFormat = jsonAdaptiveFormat.getString("type",
EMPTY_STRING);
if (streamTypeFormat.equalsIgnoreCase("FORMAT_STREAM_TYPE_OTF")) {
hasOtfStreamsValue = true;
break;
}
}
}
}
return hasOtfStreamsValue;
}
return false;
}
private boolean isCipherProtectedContent() {
if (streamingData != null) {
if (streamingData.has("adaptiveFormats")) {
final JsonArray adaptiveFormats = streamingData.getArray("adaptiveFormats");
if (!isNullOrEmpty(adaptiveFormats)) {
final JsonObject firstAdaptiveFormat = adaptiveFormats.getObject(0);
return firstAdaptiveFormat.has("cipher") || firstAdaptiveFormat.has("signatureCipher");
}
} else if (streamingData.has("formats")) {
final JsonArray formats = streamingData.getArray("formats");
if (!isNullOrEmpty(formats)) {
final JsonObject firstFormat = formats.getObject(0);
return firstFormat.has("cipher") || firstFormat.has("signatureCipher");
}
}
}
return false;
}
private String getDeobfuscationFuncName(final String playerCode) throws DeobfuscateException { private String getDeobfuscationFuncName(final String playerCode) throws DeobfuscateException {
Parser.RegexException exception = null; Parser.RegexException exception = null;
for (final String regex : REGEXES) { for (final String regex : REGEXES) {
@ -842,10 +982,8 @@ public class YoutubeStreamExtractor extends StreamExtractor {
"Could not find deobfuscate function with any of the given patterns.", exception); "Could not find deobfuscate function with any of the given patterns.", exception);
} }
private String loadDeobfuscationCode() private String loadDeobfuscationCode() throws DeobfuscateException {
throws DeobfuscateException {
try { try {
final String playerCode = YoutubeJavaScriptExtractor.extractJavaScriptCode(getId());
final String deobfuscationFunctionName = getDeobfuscationFuncName(playerCode); final String deobfuscationFunctionName = getDeobfuscationFuncName(playerCode);
final String functionPattern = "(" final String functionPattern = "("
@ -876,11 +1014,27 @@ public class YoutubeStreamExtractor extends StreamExtractor {
@Nonnull @Nonnull
private String getDeobfuscationCode() throws ParsingException { private String getDeobfuscationCode() throws ParsingException {
if (cachedDeobfuscationCode == null) { if (cachedDeobfuscationCode == null) {
if (playerCode == null) {
storePlayerJs();
if (playerCode == null) {
throw new ParsingException("Could not get YouTube's JavaScript player");
}
}
cachedDeobfuscationCode = loadDeobfuscationCode(); cachedDeobfuscationCode = loadDeobfuscationCode();
} }
return cachedDeobfuscationCode; return cachedDeobfuscationCode;
} }
private String getStsFromPlayerJs() throws ParsingException {
if (playerCode == null) {
storePlayerJs();
if (playerCode == null) throw new ParsingException("playerCode is null");
}
sts = Parser.matchGroup1(STS_REGEX, playerCode);
return sts;
}
private String deobfuscateSignature(final String obfuscatedSig) throws ParsingException { private String deobfuscateSignature(final String obfuscatedSig) throws ParsingException {
final String deobfuscationCode = getDeobfuscationCode(); final String deobfuscationCode = getDeobfuscationCode();
@ -964,8 +1118,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
final ItagItem.ItagType itagTypeWanted) final ItagItem.ItagType itagTypeWanted)
throws ParsingException { throws ParsingException {
final Map<String, ItagItem> urlAndItags = new LinkedHashMap<>(); final Map<String, ItagItem> urlAndItags = new LinkedHashMap<>();
final JsonObject streamingData = playerResponse.getObject("streamingData"); if (streamingData == null || !streamingData.has(streamingDataKey)) {
if (!streamingData.has(streamingDataKey)) {
return urlAndItags; return urlAndItags;
} }
@ -985,11 +1138,11 @@ public class YoutubeStreamExtractor extends StreamExtractor {
continue; continue;
} }
String streamUrl; final String streamUrl;
if (formatData.has("url")) { if (formatData.has("url")) {
streamUrl = formatData.getString("url"); streamUrl = formatData.getString("url");
} else { } else {
// this url has an obfuscated signature // This url has an obfuscated signature
final String cipherString = formatData.has("cipher") final String cipherString = formatData.has("cipher")
? formatData.getString("cipher") ? formatData.getString("cipher")
: formatData.getString("signatureCipher"); : formatData.getString("signatureCipher");
@ -998,10 +1151,10 @@ public class YoutubeStreamExtractor extends StreamExtractor {
+ deobfuscateSignature(cipher.get("s")); + deobfuscateSignature(cipher.get("s"));
} }
JsonObject initRange = formatData.getObject("initRange"); final JsonObject initRange = formatData.getObject("initRange");
JsonObject indexRange = formatData.getObject("indexRange"); final JsonObject indexRange = formatData.getObject("indexRange");
String mimeType = formatData.getString("mimeType", EMPTY_STRING); final String mimeType = formatData.getString("mimeType", EMPTY_STRING);
String codec = mimeType.contains("codecs") final String codec = mimeType.contains("codecs")
? mimeType.split("\"")[1] : EMPTY_STRING; ? mimeType.split("\"")[1] : EMPTY_STRING;
itagItem.setBitrate(formatData.getInt("bitrate")); itagItem.setBitrate(formatData.getInt("bitrate"));
@ -1090,7 +1243,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
result.trimToSize(); result.trimToSize();
return result; return result;
} catch (final Exception e) { } catch (final Exception e) {
throw new ExtractionException(e); throw new ExtractionException("Could not get frames", e);
} }
} }
@ -1125,7 +1278,8 @@ public class YoutubeStreamExtractor extends StreamExtractor {
final JsonArray contents = metadataRowRenderer.getArray("contents"); final JsonArray contents = metadataRowRenderer.getArray("contents");
final String license = getTextFromObject(contents.getObject(0)); final String license = getTextFromObject(contents.getObject(0));
return license != null && "Licence".equals(getTextFromObject(metadataRowRenderer.getObject("title"))) ? license : "YouTube licence"; return license != null && "Licence".equals(getTextFromObject(metadataRowRenderer
.getObject("title"))) ? license : "YouTube licence";
} }
@Override @Override
@ -1159,8 +1313,8 @@ public class YoutubeStreamExtractor extends StreamExtractor {
final String panelIdentifier = panels.getObject(i) final String panelIdentifier = panels.getObject(i)
.getObject("engagementPanelSectionListRenderer") .getObject("engagementPanelSectionListRenderer")
.getString("panelIdentifier"); .getString("panelIdentifier");
if (panelIdentifier.equals("engagement-panel-macro-markers-description-chapters") if (panelIdentifier.equals(
|| panelIdentifier.equals("engagement-panel-macro-markers")) { "engagement-panel-macro-markers-description-chapters")) {
segmentsArray = panels.getObject(i) segmentsArray = panels.getObject(i)
.getObject("engagementPanelSectionListRenderer").getObject("content") .getObject("engagementPanelSectionListRenderer").getObject("content")
.getObject("macroMarkersListRenderer").getArray("contents"); .getObject("macroMarkersListRenderer").getArray("contents");
@ -1175,8 +1329,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
.getObject("macroMarkersListItemRenderer"); .getObject("macroMarkersListItemRenderer");
final int startTimeSeconds = segmentJson.getObject("onTap") final int startTimeSeconds = segmentJson.getObject("onTap")
.getObject("watchEndpoint") .getObject("watchEndpoint").getInt("startTimeSeconds", -1);
.getInt("startTimeSeconds", -1);
if (startTimeSeconds == -1) { if (startTimeSeconds == -1) {
throw new ParsingException("Could not get stream segment start time."); throw new ParsingException("Could not get stream segment start time.");