Merge pull request #447 from Stypox/better-deobfuscation

[YouTube] Cache deobfuscation code and more fixes
This commit is contained in:
Tobias Groza 2020-11-08 00:32:36 +01:00 committed by GitHub
commit 6701b0fe71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 202 additions and 268 deletions

View file

@ -94,7 +94,7 @@ public class ItagItem {
return item; return item;
} }
} }
throw new ParsingException("itag=" + Integer.toString(itagId) + " not supported"); throw new ParsingException("itag=" + itagId + " not supported");
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////

View file

@ -3,6 +3,8 @@ package org.schabi.newpipe.extractor.services.youtube.extractors;
import com.grack.nanojson.JsonArray; import com.grack.nanojson.JsonArray;
import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonObject;
import com.grack.nanojson.JsonParser; import com.grack.nanojson.JsonParser;
import com.grack.nanojson.JsonParserException;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.nodes.Document; import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
@ -42,10 +44,10 @@ import org.schabi.newpipe.extractor.utils.Utils;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import java.io.IOException; import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@ -86,7 +88,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
// Exceptions // Exceptions
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
public class DeobfuscateException extends ParsingException { public static class DeobfuscateException extends ParsingException {
DeobfuscateException(String message, Throwable cause) { DeobfuscateException(String message, Throwable cause) {
super(message, cause); super(message, cause);
} }
@ -94,20 +96,17 @@ public class YoutubeStreamExtractor extends StreamExtractor {
/*//////////////////////////////////////////////////////////////////////////*/ /*//////////////////////////////////////////////////////////////////////////*/
@Nullable private static String cachedDeobfuscationCode = null;
@Nullable private String playerJsUrl = null;
private JsonArray initialAjaxJson; private JsonArray initialAjaxJson;
@Nullable
private JsonObject playerArgs;
@Nonnull
private final Map<String, String> videoInfoPage = new HashMap<>();
private JsonObject playerResponse;
private JsonObject initialData; private JsonObject initialData;
@Nonnull private final Map<String, String> videoInfoPage = new HashMap<>();
private JsonObject playerResponse;
private JsonObject videoPrimaryInfoRenderer; private JsonObject videoPrimaryInfoRenderer;
private JsonObject videoSecondaryInfoRenderer; private JsonObject videoSecondaryInfoRenderer;
private int ageLimit; private int ageLimit = -1;
private boolean newJsonScheme; @Nullable private List<SubtitlesStream> subtitles = null;
@Nonnull
private List<SubtitlesInfo> subtitlesInfos = new ArrayList<>();
public YoutubeStreamExtractor(StreamingService service, LinkHandler linkHandler) { public YoutubeStreamExtractor(StreamingService service, LinkHandler linkHandler) {
super(service, linkHandler); super(service, linkHandler);
@ -138,18 +137,27 @@ public class YoutubeStreamExtractor extends StreamExtractor {
return title; return title;
} }
@Nullable
@Override @Override
public String getTextualUploadDate() throws ParsingException { public String getTextualUploadDate() throws ParsingException {
if (getStreamType().equals(StreamType.LIVE_STREAM)) { final JsonObject micro =
return null; playerResponse.getObject("microformat").getObject("playerMicroformatRenderer");
} if (!micro.getString("uploadDate", EMPTY_STRING).isEmpty()) {
JsonObject micro = playerResponse.getObject("microformat").getObject("playerMicroformatRenderer");
if (micro.isString("uploadDate") && !micro.getString("uploadDate").isEmpty()) {
return micro.getString("uploadDate"); return micro.getString("uploadDate");
} } else if (!micro.getString("publishDate", EMPTY_STRING).isEmpty()) {
if (micro.isString("publishDate") && !micro.getString("publishDate").isEmpty()) {
return micro.getString("publishDate"); return micro.getString("publishDate");
} else {
final JsonObject liveDetails = micro.getObject("liveBroadcastDetails");
if (!liveDetails.getString("endTimestamp", EMPTY_STRING).isEmpty()) {
// an ended live stream
return liveDetails.getString("endTimestamp");
} else if (!liveDetails.getString("startTimestamp", EMPTY_STRING).isEmpty()) {
// a running live stream
return liveDetails.getString("startTimestamp");
} else if (getStreamType() == StreamType.LIVE_STREAM) {
// this should never be reached, but a live stream without upload date is valid
return null;
}
} }
if (getTextFromObject(getVideoPrimaryInfoRenderer().getObject("dateText")).startsWith("Premiered")) { if (getTextFromObject(getVideoPrimaryInfoRenderer().getObject("dateText")).startsWith("Premiered")) {
@ -163,8 +171,8 @@ public class YoutubeStreamExtractor extends StreamExtractor {
} }
try { // Premiered Feb 21, 2020 try { // Premiered Feb 21, 2020
LocalDate localDate = LocalDate.parse(time, final LocalDate localDate = LocalDate.parse(time,
DateTimeFormatter.ofPattern("MMM dd, YYYY", Locale.ENGLISH)); DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.ENGLISH));
return DateTimeFormatter.ISO_LOCAL_DATE.format(localDate); return DateTimeFormatter.ISO_LOCAL_DATE.format(localDate);
} catch (Exception ignored) { } catch (Exception ignored) {
} }
@ -177,6 +185,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
return DateTimeFormatter.ISO_LOCAL_DATE.format(localDate); return DateTimeFormatter.ISO_LOCAL_DATE.format(localDate);
} catch (Exception ignored) { } catch (Exception ignored) {
} }
throw new ParsingException("Could not get upload date"); throw new ParsingException("Could not get upload date");
} }
@ -224,9 +233,28 @@ public class YoutubeStreamExtractor extends StreamExtractor {
} }
@Override @Override
public int getAgeLimit() { public int getAgeLimit() throws ParsingException {
if (isNullOrEmpty(initialData)) throw new IllegalStateException("initialData is not parsed yet"); if (ageLimit == -1) {
ageLimit = NO_AGE_LIMIT;
final JsonArray metadataRows = getVideoSecondaryInfoRenderer()
.getObject("metadataRowContainer").getObject("metadataRowContainerRenderer")
.getArray("rows");
for (final Object metadataRow : metadataRows) {
final JsonArray contents = ((JsonObject) metadataRow)
.getObject("metadataRowRenderer").getArray("contents");
for (final Object content : contents) {
final JsonArray runs = ((JsonObject) content).getArray("runs");
for (final Object run : runs) {
final String rowText = ((JsonObject) run).getString("text", EMPTY_STRING);
if (rowText.contains("Age-restricted")) {
ageLimit = 18;
return ageLimit;
}
}
}
}
}
return ageLimit; return ageLimit;
} }
@ -313,8 +341,10 @@ public class YoutubeStreamExtractor extends StreamExtractor {
} catch (NumberFormatException nfe) { } catch (NumberFormatException nfe) {
throw new ParsingException("Could not parse \"" + likesString + "\" as an Integer", nfe); throw new ParsingException("Could not parse \"" + likesString + "\" as an Integer", nfe);
} catch (Exception e) { } catch (Exception e) {
if (ageLimit == 18) return -1; if (getAgeLimit() == NO_AGE_LIMIT) {
throw new ParsingException("Could not get like count", e); throw new ParsingException("Could not get like count", e);
}
return -1;
} }
} }
@ -337,8 +367,10 @@ public class YoutubeStreamExtractor extends StreamExtractor {
} catch (NumberFormatException nfe) { } catch (NumberFormatException nfe) {
throw new ParsingException("Could not parse \"" + dislikesString + "\" as an Integer", nfe); throw new ParsingException("Could not parse \"" + dislikesString + "\" as an Integer", nfe);
} catch (Exception e) { } catch (Exception e) {
if (ageLimit == 18) return -1; if (getAgeLimit() == NO_AGE_LIMIT) {
throw new ParsingException("Could not get dislike count", e); throw new ParsingException("Could not get dislike count", e);
}
return -1;
} }
} }
@ -402,8 +434,10 @@ public class YoutubeStreamExtractor extends StreamExtractor {
} }
if (isNullOrEmpty(url)) { if (isNullOrEmpty(url)) {
if (ageLimit == 18) return ""; if (ageLimit == NO_AGE_LIMIT) {
throw new ParsingException("Could not get uploader avatar URL"); throw new ParsingException("Could not get uploader avatar URL");
}
return "";
} }
return fixThumbnailUrl(url); return fixThumbnailUrl(url);
@ -411,19 +445,19 @@ public class YoutubeStreamExtractor extends StreamExtractor {
@Nonnull @Nonnull
@Override @Override
public String getSubChannelUrl() throws ParsingException { public String getSubChannelUrl() {
return ""; return "";
} }
@Nonnull @Nonnull
@Override @Override
public String getSubChannelName() throws ParsingException { public String getSubChannelName() {
return ""; return "";
} }
@Nonnull @Nonnull
@Override @Override
public String getSubChannelAvatarUrl() throws ParsingException { public String getSubChannelAvatarUrl() {
return ""; return "";
} }
@ -437,8 +471,6 @@ public class YoutubeStreamExtractor extends StreamExtractor {
return playerResponse.getObject("streamingData").getString("dashManifestUrl"); return playerResponse.getObject("streamingData").getString("dashManifestUrl");
} else if (videoInfoPage.containsKey("dashmpd")) { } else if (videoInfoPage.containsKey("dashmpd")) {
dashManifestUrl = videoInfoPage.get("dashmpd"); dashManifestUrl = videoInfoPage.get("dashmpd");
} else if (playerArgs != null && playerArgs.isString("dashmpd")) {
dashManifestUrl = playerArgs.getString("dashmpd", EMPTY_STRING);
} else { } else {
return ""; return "";
} }
@ -447,7 +479,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
String obfuscatedSig = Parser.matchGroup1("/s/([a-fA-F0-9\\.]+)", dashManifestUrl); String obfuscatedSig = Parser.matchGroup1("/s/([a-fA-F0-9\\.]+)", dashManifestUrl);
String deobfuscatedSig; String deobfuscatedSig;
deobfuscatedSig = deobfuscateSignature(obfuscatedSig, deobfuscationCode); deobfuscatedSig = deobfuscateSignature(obfuscatedSig);
dashManifestUrl = dashManifestUrl.replace("/s/" + obfuscatedSig, "/signature/" + deobfuscatedSig); dashManifestUrl = dashManifestUrl.replace("/s/" + obfuscatedSig, "/signature/" + deobfuscatedSig);
} }
@ -465,11 +497,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
try { try {
return playerResponse.getObject("streamingData").getString("hlsManifestUrl"); return playerResponse.getObject("streamingData").getString("hlsManifestUrl");
} catch (Exception e) { } catch (Exception e) {
if (playerArgs != null && playerArgs.isString("hlsvp")) { throw new ParsingException("Could not get hls manifest url", e);
return playerArgs.getString("hlsvp");
} else {
throw new ParsingException("Could not get hls manifest url", e);
}
} }
} }
@ -535,35 +563,57 @@ public class YoutubeStreamExtractor extends StreamExtractor {
@Override @Override
@Nonnull @Nonnull
public List<SubtitlesStream> getSubtitlesDefault() { public List<SubtitlesStream> getSubtitlesDefault() throws ParsingException {
return getSubtitles(MediaFormat.TTML); return getSubtitles(MediaFormat.TTML);
} }
@Override @Override
@Nonnull @Nonnull
public List<SubtitlesStream> getSubtitles(final MediaFormat format) { public List<SubtitlesStream> getSubtitles(final MediaFormat format) throws ParsingException {
assertPageFetched(); assertPageFetched();
List<SubtitlesStream> subtitles = new ArrayList<>(); // If the video is age restricted getPlayerConfig will fail
for (final SubtitlesInfo subtitlesInfo : subtitlesInfos) { if (getAgeLimit() != NO_AGE_LIMIT) {
subtitles.add(subtitlesInfo.getSubtitle(format)); return Collections.emptyList();
} }
if (subtitles != null) {
// already calculated
return subtitles;
}
final JsonObject renderer = playerResponse.getObject("captions")
.getObject("playerCaptionsTracklistRenderer");
final JsonArray captionsArray = renderer.getArray("captionTracks");
// TODO: use this to apply auto translation to different language from a source language
// final JsonArray autoCaptionsArray = renderer.getArray("translationLanguages");
subtitles = new ArrayList<>();
for (int i = 0; i < captionsArray.size(); i++) {
final String languageCode = captionsArray.getObject(i).getString("languageCode");
final String baseUrl = captionsArray.getObject(i).getString("baseUrl");
final String vssId = captionsArray.getObject(i).getString("vssId");
if (languageCode != null && baseUrl != null && vssId != null) {
final boolean isAutoGenerated = vssId.startsWith("a.");
final String cleanUrl = baseUrl
.replaceAll("&fmt=[^&]*", "") // Remove preexisting format if exists
.replaceAll("&tlang=[^&]*", ""); // Remove translation language
subtitles.add(new SubtitlesStream(format, languageCode,
cleanUrl + "&fmt=" + format.getSuffix(), isAutoGenerated));
}
}
return subtitles; return subtitles;
} }
@Override @Override
public StreamType getStreamType() throws ParsingException { public StreamType getStreamType() {
assertPageFetched(); assertPageFetched();
try { return playerResponse.getObject("streamingData").has(FORMATS)
if (!playerResponse.getObject("streamingData").has(FORMATS) || ? StreamType.VIDEO_STREAM : StreamType.LIVE_STREAM;
(playerArgs != null && playerArgs.has("ps") && playerArgs.get("ps").toString().equals("live"))) {
return StreamType.LIVE_STREAM;
}
} catch (Exception e) {
throw new ParsingException("Could not get stream type", e);
}
return StreamType.VIDEO_STREAM;
} }
@Nullable
private StreamInfoItemExtractor getNextStream() throws ExtractionException { private StreamInfoItemExtractor getNextStream() throws ExtractionException {
try { try {
final JsonObject firstWatchNextItem = initialData.getObject("contents") final JsonObject firstWatchNextItem = initialData.getObject("contents")
@ -648,48 +698,24 @@ public class YoutubeStreamExtractor extends StreamExtractor {
"\\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 volatile String deobfuscationCode = "";
@Override @Override
public void onFetchPage(@Nonnull Downloader downloader) throws IOException, ExtractionException { public void onFetchPage(@Nonnull final Downloader downloader)
final String url = getUrl() + "&pbj=1"; throws IOException, ExtractionException {
final String playerUrl; initialAjaxJson = getJsonResponse(getUrl() + "&pbj=1", getExtractorLocalization());
initialAjaxJson = getJsonResponse(url, getExtractorLocalization()); initialData = initialAjaxJson.getObject(3).getObject("response", null);
if (initialData == null) {
if (initialAjaxJson.getObject(2).has("response")) { // age-restricted videos initialData = initialAjaxJson.getObject(2).getObject("response", null);
initialData = initialAjaxJson.getObject(2).getObject("response"); if (initialData == null) {
ageLimit = 18; throw new ParsingException("Could not get initial data");
final EmbeddedInfo info = getEmbeddedInfo();
final String videoInfoUrl = getVideoInfoUrl(getId(), info.sts);
final String infoPageResponse = downloader.get(videoInfoUrl, getExtractorLocalization()).responseBody();
videoInfoPage.putAll(Parser.compatParseMap(infoPageResponse));
playerUrl = info.url;
} else {
ageLimit = NO_AGE_LIMIT;
JsonObject playerConfig;
initialData = initialAjaxJson.getObject(3).getObject("response");
// sometimes at random YouTube does not provide the player config
playerConfig = initialAjaxJson.getObject(2).getObject("player", null);
if (playerConfig == null) {
newJsonScheme = true;
final EmbeddedInfo info = getEmbeddedInfo();
final String videoInfoUrl = getVideoInfoUrl(getId(), info.sts);
final String infoPageResponse = downloader.get(videoInfoUrl, getExtractorLocalization()).responseBody();
videoInfoPage.putAll(Parser.compatParseMap(infoPageResponse));
playerUrl = info.url;
} else {
playerArgs = getPlayerArgs(playerConfig);
playerUrl = getPlayerUrl(playerConfig);
} }
} }
playerResponse = getPlayerResponse(); playerResponse = initialAjaxJson.getObject(2).getObject("playerResponse", null);
if (playerResponse == null || !playerResponse.has("streamingData")) {
// try to get player response by fetching video info page
fetchVideoInfoPage();
}
final JsonObject playabilityStatus = playerResponse.getObject("playabilityStatus"); final JsonObject playabilityStatus = playerResponse.getObject("playabilityStatus");
final String status = playabilityStatus.getString("status"); final String status = playabilityStatus.getString("status");
@ -698,117 +724,77 @@ public class YoutubeStreamExtractor extends StreamExtractor {
final String reason = playabilityStatus.getString("reason"); final String reason = playabilityStatus.getString("reason");
throw new ContentNotAvailableException("Got error: \"" + reason + "\""); throw new ContentNotAvailableException("Got error: \"" + reason + "\"");
} }
if (deobfuscationCode.isEmpty()) {
deobfuscationCode = loadDeobfuscationCode(playerUrl);
}
if (subtitlesInfos.isEmpty()) {
subtitlesInfos.addAll(getAvailableSubtitlesInfo());
}
} }
private JsonObject getPlayerArgs(final JsonObject playerConfig) throws ParsingException { private void fetchVideoInfoPage() throws ParsingException, ReCaptchaException, IOException {
//attempt to load the youtube js player JSON arguments final String sts = getEmbeddedInfoStsAndStorePlayerJsUrl();
final JsonObject playerArgs = playerConfig.getObject("args", null); final String videoInfoUrl = getVideoInfoUrl(getId(), sts);
if (playerArgs == null) { final String infoPageResponse = NewPipe.getDownloader()
throw new ParsingException("Could not extract args from YouTube player config"); .get(videoInfoUrl, getExtractorLocalization()).responseBody();
} videoInfoPage.putAll(Parser.compatParseMap(infoPageResponse));
return playerArgs;
}
private String getPlayerUrl(final JsonObject playerConfig) throws ParsingException {
// The Youtube service needs to be initialized by downloading the
// js-Youtube-player. This is done in order to get the algorithm
// for deobfuscating cryptic signatures inside certain stream URLs.
final String playerUrl = playerConfig.getObject("assets").getString("js");
if (playerUrl == null) {
throw new ParsingException("Could not extract js URL from YouTube player config");
} else if (playerUrl.startsWith("//")) {
return HTTPS + playerUrl;
}
return playerUrl;
}
private JsonObject getPlayerResponse() throws ParsingException {
try { try {
String playerResponseStr; playerResponse = JsonParser.object().from(videoInfoPage.get("player_response"));
if (newJsonScheme) { } catch (JsonParserException e) {
return initialAjaxJson.getObject(2).getObject("playerResponse"); throw new ParsingException(
} "Could not parse YouTube player response from video info page", e);
if (playerArgs != null) {
playerResponseStr = playerArgs.getString("player_response");
} else {
playerResponseStr = videoInfoPage.get("player_response");
}
return JsonParser.object().from(playerResponseStr);
} catch (Exception e) {
throw new ParsingException("Could not parse yt player response", e);
} }
} }
@Nonnull @Nonnull
private EmbeddedInfo getEmbeddedInfo() throws ParsingException, ReCaptchaException { private String getEmbeddedInfoStsAndStorePlayerJsUrl() {
try { try {
final Downloader downloader = NewPipe.getDownloader();
final String embedUrl = "https://www.youtube.com/embed/" + getId(); final String embedUrl = "https://www.youtube.com/embed/" + getId();
final String embedPageContent = downloader.get(embedUrl, getExtractorLocalization()).responseBody(); final String embedPageContent = NewPipe.getDownloader()
.get(embedUrl, getExtractorLocalization()).responseBody();
// Get player url
String playerUrl = null;
try { try {
final String assetsPattern = "\"assets\":.+?\"js\":\\s*(\"[^\"]+\")"; final String assetsPattern = "\"assets\":.+?\"js\":\\s*(\"[^\"]+\")";
playerUrl = Parser.matchGroup1(assetsPattern, embedPageContent) playerJsUrl = Parser.matchGroup1(assetsPattern, embedPageContent)
.replace("\\", "").replace("\"", ""); .replace("\\", "").replace("\"", "");
} catch (Parser.RegexException ex) { } catch (Parser.RegexException ex) {
// playerUrl is still available in the file, just somewhere else // 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()
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 (Element elem : elems) { for (Element elem : elems) {
if (elem.attr("src").contains("base.js")) { if (elem.attr("src").contains("base.js")) {
playerUrl = elem.attr("src"); playerJsUrl = elem.attr("src");
break;
} }
} }
if (playerUrl == null) {
throw new ParsingException("Could not get playerUrl");
}
} }
if (playerUrl.startsWith("//")) { // Get embed sts
playerUrl = HTTPS + playerUrl; return Parser.matchGroup1("\"sts\"\\s*:\\s*(\\d+)", embedPageContent);
} else if (playerUrl.startsWith("/")) { } catch (Exception i) {
playerUrl = HTTPS + "//youtube.com" + playerUrl; // if it fails we simply reply with no sts as then it does not seem to be necessary
} return "";
try {
// Get embed sts
final String stsPattern = "\"sts\"\\s*:\\s*(\\d+)";
final String sts = Parser.matchGroup1(stsPattern, embedPageContent);
return new EmbeddedInfo(playerUrl, sts);
} catch (Exception i) {
// if it fails we simply reply with no sts as then it does not seem to be necessary
return new EmbeddedInfo(playerUrl, "");
}
} catch (IOException e) {
throw new ParsingException(
"Could not load deobfuscation code from YouTube video embed", e);
} }
} }
private String loadDeobfuscationCode(String playerUrl) throws DeobfuscateException {
try {
Downloader downloader = NewPipe.getDownloader();
if (!playerUrl.contains("https://youtube.com")) {
//sometimes the https://youtube.com part does not get send with
//than we have to add it by hand
playerUrl = "https://youtube.com" + playerUrl;
}
final String playerCode = downloader.get(playerUrl, getExtractorLocalization()).responseBody();
private String getDeobfuscationFuncName(final String playerCode) throws DeobfuscateException {
Parser.RegexException exception = null;
for (final String regex : REGEXES) {
try {
return Parser.matchGroup1(regex, playerCode);
} catch (Parser.RegexException re) {
if (exception == null) {
exception = re;
}
}
}
throw new DeobfuscateException("Could not find deobfuscate function with any of the given patterns.", exception);
}
private String loadDeobfuscationCode(@Nonnull final String playerJsUrl)
throws DeobfuscateException {
try {
final String playerCode = NewPipe.getDownloader()
.get(playerJsUrl, getExtractorLocalization()).responseBody();
final String deobfuscationFunctionName = getDeobfuscationFuncName(playerCode); final String deobfuscationFunctionName = getDeobfuscationFuncName(playerCode);
final String functionPattern = "(" final String functionPattern = "("
@ -834,7 +820,34 @@ public class YoutubeStreamExtractor extends StreamExtractor {
} }
} }
private String deobfuscateSignature(String obfuscatedSig, String deobfuscationCode) throws DeobfuscateException { @Nonnull
private String getDeobfuscationCode() throws ParsingException {
if (cachedDeobfuscationCode == null) {
if (playerJsUrl == null) {
// the currentPlayerJsUrl was not found in any page fetched so far and there is
// nothing cached, so try fetching embedded info
getEmbeddedInfoStsAndStorePlayerJsUrl();
if (playerJsUrl == null) {
throw new ParsingException(
"Embedded info did not provide YouTube player js url");
}
}
if (playerJsUrl.startsWith("//")) {
playerJsUrl = HTTPS + playerJsUrl;
} else if (playerJsUrl.startsWith("/")) {
// sometimes https://youtube.com part has to be added manually
playerJsUrl = HTTPS + "//youtube.com" + playerJsUrl;
}
cachedDeobfuscationCode = loadDeobfuscationCode(playerJsUrl);
}
return cachedDeobfuscationCode;
}
private String deobfuscateSignature(final String obfuscatedSig) throws ParsingException {
final String deobfuscationCode = getDeobfuscationCode();
final Context context = Context.enter(); final Context context = Context.enter();
context.setOptimizationLevel(-1); context.setOptimizationLevel(-1);
final Object result; final Object result;
@ -851,88 +864,6 @@ public class YoutubeStreamExtractor extends StreamExtractor {
return result == null ? "" : result.toString(); return result == null ? "" : result.toString();
} }
private String getDeobfuscationFuncName(final String playerCode) throws DeobfuscateException {
Parser.RegexException exception = null;
for (final String regex : REGEXES) {
try {
return Parser.matchGroup1(regex, playerCode);
} catch (Parser.RegexException re) {
if (exception == null) {
exception = re;
}
}
}
throw new DeobfuscateException("Could not find deobfuscate function with any of the given patterns.", exception);
}
@Nonnull
private List<SubtitlesInfo> getAvailableSubtitlesInfo() {
// If the video is age restricted getPlayerConfig will fail
if (getAgeLimit() != NO_AGE_LIMIT) return Collections.emptyList();
final JsonObject captions;
if (!playerResponse.has("captions")) {
// Captions does not exist
return Collections.emptyList();
}
captions = playerResponse.getObject("captions");
final JsonObject renderer = captions.getObject("playerCaptionsTracklistRenderer");
final JsonArray captionsArray = renderer.getArray("captionTracks");
// todo: use this to apply auto translation to different language from a source language
// final JsonArray autoCaptionsArray = renderer.getArray("translationLanguages");
// This check is necessary since there may be cases where subtitles metadata do not contain caption track info
// e.g. https://www.youtube.com/watch?v=-Vpwatutnko
final int captionsSize = captionsArray.size();
if (captionsSize == 0) return Collections.emptyList();
List<SubtitlesInfo> result = new ArrayList<>();
for (int i = 0; i < captionsSize; i++) {
final String languageCode = captionsArray.getObject(i).getString("languageCode");
final String baseUrl = captionsArray.getObject(i).getString("baseUrl");
final String vssId = captionsArray.getObject(i).getString("vssId");
if (languageCode != null && baseUrl != null && vssId != null) {
final boolean isAutoGenerated = vssId.startsWith("a.");
result.add(new SubtitlesInfo(baseUrl, languageCode, isAutoGenerated));
}
}
return result;
}
/*//////////////////////////////////////////////////////////////////////////
// Data Class
//////////////////////////////////////////////////////////////////////////*/
private class EmbeddedInfo {
final String url;
final String sts;
EmbeddedInfo(final String url, final String sts) {
this.url = url;
this.sts = sts;
}
}
private class SubtitlesInfo {
final String cleanUrl;
final String languageCode;
final boolean isGenerated;
public SubtitlesInfo(final String baseUrl, final String languageCode, final boolean isGenerated) {
this.cleanUrl = baseUrl
.replaceAll("&fmt=[^&]*", "") // Remove preexisting format if exists
.replaceAll("&tlang=[^&]*", ""); // Remove translation language
this.languageCode = languageCode;
this.isGenerated = isGenerated;
}
public SubtitlesStream getSubtitle(final MediaFormat format) {
return new SubtitlesStream(format, languageCode, cleanUrl + "&fmt=" + format.getSuffix(), isGenerated);
}
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Utils // Utils
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -989,14 +920,16 @@ public class YoutubeStreamExtractor extends StreamExtractor {
"&sts=" + sts + "&ps=default&gl=US&hl=en"; "&sts=" + sts + "&ps=default&gl=US&hl=en";
} }
private Map<String, ItagItem> getItags(String streamingDataKey, ItagItem.ItagType itagTypeWanted) throws ParsingException { private Map<String, ItagItem> getItags(final String streamingDataKey,
Map<String, ItagItem> urlAndItags = new LinkedHashMap<>(); final ItagItem.ItagType itagTypeWanted)
JsonObject streamingData = playerResponse.getObject("streamingData"); throws ParsingException {
final Map<String, ItagItem> urlAndItags = new LinkedHashMap<>();
final JsonObject streamingData = playerResponse.getObject("streamingData");
if (!streamingData.has(streamingDataKey)) { if (!streamingData.has(streamingDataKey)) {
return urlAndItags; return urlAndItags;
} }
JsonArray formats = streamingData.getArray(streamingDataKey); final JsonArray formats = streamingData.getArray(streamingDataKey);
for (int i = 0; i != formats.size(); ++i) { for (int i = 0; i != formats.size(); ++i) {
JsonObject formatData = formats.getObject(i); JsonObject formatData = formats.getObject(i);
int itag = formatData.getInt("itag"); int itag = formatData.getInt("itag");
@ -1022,7 +955,7 @@ public class YoutubeStreamExtractor extends StreamExtractor {
: formatData.getString("signatureCipher"); : formatData.getString("signatureCipher");
final Map<String, String> cipher = Parser.compatParseMap(cipherString); final Map<String, String> cipher = Parser.compatParseMap(cipherString);
streamUrl = cipher.get("url") + "&" + cipher.get("sp") + "=" streamUrl = cipher.get("url") + "&" + cipher.get("sp") + "="
+ deobfuscateSignature(cipher.get("s"), deobfuscationCode); + deobfuscateSignature(cipher.get("s"));
} }
urlAndItags.put(streamUrl, itagItem); urlAndItags.put(streamUrl, itagItem);

View file

@ -209,6 +209,7 @@ public abstract class DefaultStreamExtractorTest extends DefaultExtractorTest<St
final StreamInfoItemsCollector relatedStreams = extractor().getRelatedStreams(); final StreamInfoItemsCollector relatedStreams = extractor().getRelatedStreams();
if (expectedHasRelatedStreams()) { if (expectedHasRelatedStreams()) {
assertNotNull(relatedStreams);
defaultTestListOfItems(extractor().getService(), relatedStreams.getItems(), defaultTestListOfItems(extractor().getService(), relatedStreams.getItems(),
relatedStreams.getErrors()); relatedStreams.getErrors());
} else { } else {

View file

@ -45,8 +45,8 @@ public class YoutubeStreamExtractorLivestreamTest extends DefaultStreamExtractor
@Override public long expectedLength() { return 0; } @Override public long expectedLength() { return 0; }
@Override public long expectedTimestamp() { return TIMESTAMP; } @Override public long expectedTimestamp() { return TIMESTAMP; }
@Override public long expectedViewCountAtLeast() { return 0; } @Override public long expectedViewCountAtLeast() { return 0; }
@Nullable @Override public String expectedUploadDate() { return null; } @Nullable @Override public String expectedUploadDate() { return "2020-02-22 00:00:00.000"; }
@Nullable @Override public String expectedTextualUploadDate() { return null; } @Nullable @Override public String expectedTextualUploadDate() { return "2020-02-22"; }
@Override public long expectedLikeCountAtLeast() { return 825000; } @Override public long expectedLikeCountAtLeast() { return 825000; }
@Override public long expectedDislikeCountAtLeast() { return 15600; } @Override public long expectedDislikeCountAtLeast() { return 15600; }
@Override public boolean expectedHasSubtitles() { return false; } @Override public boolean expectedHasSubtitles() { return false; }