diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java index f4e7c41b..2325cefa 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java @@ -213,6 +213,8 @@ private YoutubeParsingHelper() { private static final String[] INITIAL_DATA_REGEXES = {"window\\[\"ytInitialData\"\\]\\s*=\\s*(\\{.*?\\});", "var\\s*ytInitialData\\s*=\\s*(\\{.*?\\});"}; + private static final String[] YTCFG_REGEXES = + {"ytcfg\\.set\\s*\\(\\s*(\\{.+?\\})\\s*\\)\\s*;"}; private static final String CONTENT_PLAYBACK_NONCE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; @@ -1795,6 +1797,37 @@ public static void addLoggedInHeaders(@Nonnull final Map> h } } + @Nonnull + public static JsonObject getWebWatchYtcfg(@Nonnull final String videoId) + throws IOException, ExtractionException { + final Map> headers = new HashMap<>(getCookieHeader()); + if (ServiceList.YouTube.hasTokens()) { + headers.put("Cookie", singletonList(ServiceList.YouTube.getTokens())); + } + headers.put("User-Agent", singletonList(WEB_USER_AGENT)); + + final String html = getDownloader().get( + "https://www.youtube.com/watch?v=" + videoId, headers).responseBody(); + final JsonObject mergedYtcfg = new JsonObject(); + final java.util.regex.Matcher matcher = Pattern.compile(YTCFG_REGEXES[0]).matcher(html); + while (matcher.find()) { + try { + final JsonObject ytcfg = JsonParser.object().from(matcher.group(1)); + mergedYtcfg.putAll(ytcfg); + if (!isNullOrEmpty(mergedYtcfg.getString("VISITOR_DATA", "")) + && mergedYtcfg.get("SESSION_INDEX") != null) { + return mergedYtcfg; + } + } catch (final JsonParserException ignored) { + // Some pages include small ytcfg fragments; keep looking for the full config. + } + } + if (!mergedYtcfg.isEmpty()) { + return mergedYtcfg; + } + throw new ParsingException("Could not get ytcfg"); + } + /** * Returns a {@link Map} containing the required YouTube Music headers. */ @@ -2411,20 +2444,32 @@ public static String getAuthorizationHeader(String cookie) throws NoSuchAlgorith String ytURL = "https://www.youtube.com"; Map cookies = parseCookies(cookie); - String sapisid = cookies.get("SAPISID"); + final List authorizations = new ArrayList<>(); - if (sapisid == null) { - sapisid = cookies.get("__Secure-3PAPISID"); - if (sapisid == null) { - throw new IllegalArgumentException("SAPISID not found in cookies"); - } + appendAuthorization(authorizations, "SAPISIDHASH", cookies.get("SAPISID"), ytURL); + appendAuthorization(authorizations, "SAPISID1PHASH", cookies.get("__Secure-1PAPISID"), ytURL); + appendAuthorization(authorizations, "SAPISID3PHASH", cookies.get("__Secure-3PAPISID"), ytURL); + + if (authorizations.isEmpty()) { + throw new IllegalArgumentException("SAPISID not found in cookies"); } + return String.join(" ", authorizations); + } + + private static void appendAuthorization(@Nonnull final List authorizations, + @Nonnull final String scheme, + @Nullable final String sid, + @Nonnull final String ytURL) + throws NoSuchAlgorithmException { + if (isNullOrEmpty(sid)) { + return; + } long currentTimestamp = Instant.now().getEpochSecond(); - String initialData = currentTimestamp + " " + sapisid + " " + ytURL; + String initialData = currentTimestamp + " " + sid + " " + ytURL; String hash = sha1(initialData); - return "SAPISIDHASH " + currentTimestamp + "_" + hash; + authorizations.add(scheme + " " + currentTimestamp + "_" + hash); } @Nonnull diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java index 7e2d923f..deb90661 100644 --- a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/extractors/YoutubeStreamExtractor.java @@ -39,6 +39,8 @@ import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrProbe; import org.schabi.newpipe.extractor.stream.*; import org.schabi.newpipe.extractor.utils.JsonUtils; +import org.schabi.newpipe.extractor.utils.Parser; +import org.schabi.newpipe.extractor.utils.Pair; import org.schabi.newpipe.extractor.utils.SubtitleDeduplicator; import org.schabi.newpipe.extractor.utils.Utils; @@ -82,6 +84,8 @@ public static class DeobfuscateException extends ParsingException { @Nullable private JsonObject mwebStreamingData; @Nullable + private JsonObject tvStreamingData; + @Nullable private JsonObject configuredStreamingData; private JsonObject videoPrimaryInfoRenderer; @@ -98,6 +102,7 @@ public static class DeobfuscateException extends ParsingException { // two different strings are used. private String webCpn; private String mwebCpn; + private String tvCpn; public WatchDataCache watchDataCache; @@ -298,7 +303,7 @@ public long getLength() throws ParsingException { return Long.parseLong(duration); } catch (final Exception e) { return getDurationFromFirstAdaptiveFormat(Arrays.asList( - webStreamingData, mwebStreamingData)); + webStreamingData, mwebStreamingData, tvStreamingData)); } } @@ -716,7 +721,7 @@ public String getHlsUrl() throws ParsingException { String hlsUrl = getManifestUrl( "hls", - Arrays.asList(webStreamingData, mwebStreamingData)); + Arrays.asList(webStreamingData, mwebStreamingData, tvStreamingData)); if (!hlsUrl.isEmpty()) { hlsUrl = deobfuscateManifestUrl(hlsUrl); @@ -792,6 +797,8 @@ && hasSabrStreamingUrl()) { buildSabrStreams(videoId); } else if (streamType == StreamType.POST_LIVE_STREAM) { tryExtractHlsStreams(videoId); + } else { + buildDirectStreams(videoId); } streamsCached = true; } catch (final Exception e) { @@ -799,6 +806,69 @@ && hasSabrStreamingUrl()) { } } + private void buildDirectStreams(@Nonnull final String videoId) throws ExtractionException { + final List allItagInfos = new ArrayList<>(); + final int audioStartIndex = 0; + final int videoStartIndex; + final int videoOnlyStartIndex; + + java.util.stream.Stream.of( + new Pair<>(webStreamingData, webCpn), + new Pair<>(mwebStreamingData, mwebCpn), + new Pair<>(tvStreamingData, tvCpn)) + .flatMap(pair -> getStreamsFromStreamingDataKey(videoId, pair.getFirst(), + ADAPTIVE_FORMATS, ItagItem.ItagType.AUDIO, pair.getSecond())) + .forEachOrdered(allItagInfos::add); + + videoStartIndex = allItagInfos.size(); + + java.util.stream.Stream.of( + new Pair<>(webStreamingData, webCpn), + new Pair<>(mwebStreamingData, mwebCpn), + new Pair<>(tvStreamingData, tvCpn)) + .flatMap(pair -> getStreamsFromStreamingDataKey(videoId, pair.getFirst(), + FORMATS, ItagItem.ItagType.VIDEO, pair.getSecond())) + .forEachOrdered(allItagInfos::add); + + videoOnlyStartIndex = allItagInfos.size(); + + java.util.stream.Stream.of( + new Pair<>(webStreamingData, webCpn), + new Pair<>(mwebStreamingData, mwebCpn), + new Pair<>(tvStreamingData, tvCpn)) + .flatMap(pair -> getStreamsFromStreamingDataKey(videoId, pair.getFirst(), + ADAPTIVE_FORMATS, ItagItem.ItagType.VIDEO_ONLY, pair.getSecond())) + .forEachOrdered(allItagInfos::add); + + batchDeobfuscateItagUrls(videoId, allItagInfos); + + for (int i = audioStartIndex; i < videoStartIndex; i++) { + final AudioStream stream = getAudioStreamBuilderHelper().apply(allItagInfos.get(i)); + if (!Stream.containSimilarStream(stream, cachedAudioStreams)) { + cachedAudioStreams.add(stream); + } + } + Collections.sort(cachedAudioStreams, Comparator.comparingInt(AudioStream::getBitrate).reversed()); + + for (int i = videoStartIndex; i < videoOnlyStartIndex; i++) { + final VideoStream stream = getVideoStreamBuilderHelper(false).apply(allItagInfos.get(i)); + if (!Stream.containSimilarStream(stream, cachedVideoStreams)) { + cachedVideoStreams.add(stream); + } + } + + for (int i = videoOnlyStartIndex; i < allItagInfos.size(); i++) { + final VideoStream stream = getVideoStreamBuilderHelper(true).apply(allItagInfos.get(i)); + if (!Stream.containSimilarStream(stream, cachedVideoOnlyStreams)) { + cachedVideoOnlyStreams.add(stream); + } + } + + if (cachedAudioStreams.isEmpty() && cachedVideoOnlyStreams.isEmpty()) { + tryExtractHlsStreams(videoId); + } + } + /** * Build session-based SABR streams from a SABR-only response. * @@ -993,7 +1063,7 @@ private void tryExtractHlsStreams(final String videoId) throws ExtractionExcepti @Nonnull private String getHlsManifestUrlFromStreamingData() { for (final JsonObject sd : Arrays.asList( - webStreamingData, mwebStreamingData)) { + webStreamingData, mwebStreamingData, tvStreamingData)) { if (sd != null) { final String url = sd.getString("hlsManifestUrl"); if (url != null && !url.isEmpty()) { @@ -1424,9 +1494,12 @@ public String getErrorMessage() { //////////////////////////////////////////////////////////////////////////*/ private static final String ADAPTIVE_FORMATS = "adaptiveFormats"; + private static final String FORMATS = "formats"; 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 synchronized void updateAvailableAt(@Nonnull final JsonObject response) { final double[] waitSeconds = {0}; @@ -1569,11 +1642,18 @@ public void onSuccess(Response response) throws ExtractionException { }; awaitRequiredCalls(requiredCalls, ServiceList.YouTube.getLoadingTimeout()); + if ((playerResponse == null || (webStreamingData == null && mwebStreamingData == null)) + && ServiceList.YouTube.hasTokens()) { + fetchTvDowngradedJsonPlayer(contentCountry, localization, videoId); + } + if (tvStreamingData != null) { + removeLoggedInAgeGateErrorsAfterTvFallback(); + } throwIfErrors(); if (playerResponse == null) { throw new ExtractionException("YouTube player response is missing"); } - if (webStreamingData == null && mwebStreamingData == null) { + if (webStreamingData == null && mwebStreamingData == null && tvStreamingData == null) { throw new ExtractionException("YouTube streaming data is missing"); } if (nextResponse == null) { @@ -1617,6 +1697,14 @@ private static void cancelCalls(@Nonnull final CancellableCall[] calls) { } } + private void removeLoggedInAgeGateErrorsAfterTvFallback() { + synchronized (errors) { + errors.removeIf(error -> error instanceof AgeRestrictedContentException + && error.getMessage() != null + && error.getMessage().contains("anonymously")); + } + } + private void throwIfErrors() throws ExtractionException { final List recordedErrors; synchronized (errors) { @@ -1826,6 +1914,97 @@ public void onError(final Exception error) { MWEB_USER_AGENT), localization, "2", MWEB_USER_AGENT, callback); } + private void fetchTvDowngradedJsonPlayer(@Nonnull final ContentCountry contentCountry, + @Nonnull final Localization localization, + @Nonnull final String videoId) + throws IOException, ExtractionException { + if (!ServiceList.YouTube.hasTokens()) { + return; + } + + tvCpn = generateContentPlaybackNonce(); + final JsonObject ytcfg = getWebWatchYtcfg(videoId); + final String visitorData = ytcfg.getString("VISITOR_DATA", EMPTY_STRING); + final Object sessionIndexObject = ytcfg.get("SESSION_INDEX"); + final String sessionIndex = sessionIndexObject == null + ? EMPTY_STRING : String.valueOf(sessionIndexObject); + + final byte[] body = JsonWriter.string( + JsonObject.builder() + .object("context") + .object("client") + .value("clientName", "TVHTML5") + .value("clientVersion", "5.20260114") + .value("hl", localization.getLocalizationCode()) + .value("gl", contentCountry.getCountryCode()) + .value("timeZone", "UTC") + .value("userAgent", "Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version") + .value("utcOffsetMinutes", 0) + .end() + .object("request") + .array("internalExperimentFlags") + .end() + .value("useSsl", true) + .end() + .object("user") + .value("lockedSafetyMode", false) + .end() + .end() + .object("playbackContext") + .object("contentPlaybackContext") + .value("html5Preference", "HTML5_PREF_WANTS") + .value("signatureTimestamp", + YoutubeJavaScriptPlayerManager.getSignatureTimestamp(videoId)) + .end() + .end() + .value(VIDEO_ID, videoId) + .value(CONTENT_CHECK_OK, true) + .value(RACY_CHECK_OK, true) + .done()) + .getBytes(StandardCharsets.UTF_8); + + final Map> headers = new HashMap<>(); + headers.put("Content-Type", singletonList("application/json")); + headers.put("User-Agent", singletonList("Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version")); + headers.put("Origin", singletonList("https://www.youtube.com")); + headers.put("X-YouTube-Client-Name", singletonList("7")); + headers.put("X-YouTube-Client-Version", singletonList("5.20260114")); + addLoggedInHeaders(headers); + if (!isNullOrEmpty(visitorData)) { + headers.put("X-Goog-Visitor-Id", singletonList(visitorData)); + } + if (!isNullOrEmpty(sessionIndex)) { + headers.put("X-Goog-AuthUser", singletonList(sessionIndex)); + } + + final Response response = NewPipe.getDownloader().post( + YOUTUBEI_V1_URL + PLAYER + "?" + DISABLE_PRETTY_PRINT_PARAMETER, + headers, body, localization); + final JsonObject tvPlayerResponse = + JsonUtils.toJsonObject(getValidJsonResponseBody(response)); + if (isPlayerResponseNotValid(tvPlayerResponse, videoId)) { + throw new ExtractionException("TV player response is not valid"); + } + + final JsonObject streamingData = tvPlayerResponse.getObject(STREAMING_DATA); + if (!isNullOrEmpty(streamingData)) { + if (playerResponse == null) { + playerResponse = tvPlayerResponse; + } else { + playerResponse.put("playabilityStatus", + tvPlayerResponse.getObject("playabilityStatus")); + playerResponse.put(STREAMING_DATA, streamingData); + if (isNullOrEmpty(playerResponse.getObject("videoDetails")) + && !isNullOrEmpty(tvPlayerResponse.getObject("videoDetails"))) { + playerResponse.put("videoDetails", tvPlayerResponse.getObject("videoDetails")); + } + } + updateAvailableAt(playerResponse); + tvStreamingData = streamingData; + configuredStreamingData = streamingData; + } + } + /** * Checks whether an additional player response is not valid. @@ -1859,6 +2038,274 @@ public static boolean isPlayerResponseNotValid( .getString("videoId", "")); } + @Nonnull + private java.util.function.Function getAudioStreamBuilderHelper() { + return itagInfo -> { + final ItagItem itagItem = itagInfo.getItagItem(); + try { + final String randomString = UUID.randomUUID().toString().replaceAll("[^a-zA-Z]", ""); + final AudioStream.Builder builder = new AudioStream.Builder() + .setAvailableAt(getStreamAvailableAt()) + .setId(randomString) + .setContent(itagInfo.getContent() + + (itagInfo.getIsUrl() ? ("&pppid=" + getId()) : ""), + itagInfo.getIsUrl()) + .setMediaFormat(itagItem.getMediaFormat()) + .setAverageBitrate(itagItem.getAverageBitrate()) + .setItagItem(itagItem) + .setAudioTrackId(itagInfo.getAudioTrackId()) + .setAudioTrackName(itagInfo.getAudioTrackName()) + .setAudioLocale(itagInfo.getAudioLocale()); + + if (streamType == StreamType.LIVE_STREAM + || streamType == StreamType.POST_LIVE_STREAM + || !itagInfo.getIsUrl()) { + builder.setDeliveryMethod(DeliveryMethod.DASH); + } + return builder.build(); + } catch (final ParsingException e) { + throw new RuntimeException(e); + } + }; + } + + @Nonnull + private java.util.function.Function getVideoStreamBuilderHelper( + final boolean areStreamsVideoOnly) { + return itagInfo -> { + final ItagItem itagItem = itagInfo.getItagItem(); + try { + final VideoStream.Builder builder = new VideoStream.Builder() + .setAvailableAt(getStreamAvailableAt()) + .setId(String.valueOf(itagItem.id)) + .setContent(itagInfo.getContent() + + (itagInfo.getIsUrl() ? ("&pppid=" + getId()) : ""), + itagInfo.getIsUrl()) + .setMediaFormat(itagItem.getMediaFormat()) + .setIsVideoOnly(areStreamsVideoOnly) + .setItagItem(itagItem); + + final String resolutionString = itagItem.getResolutionString(); + builder.setResolution(resolutionString != null ? resolutionString : EMPTY_STRING); + + if (streamType != StreamType.VIDEO_STREAM || !itagInfo.getIsUrl()) { + builder.setDeliveryMethod(DeliveryMethod.DASH); + } + return builder.build(); + } catch (final ParsingException e) { + throw new RuntimeException(e); + } + }; + } + + @Nonnull + private java.util.stream.Stream getStreamsFromStreamingDataKey( + final String videoId, + @Nullable final JsonObject streamingData, + final String streamingDataKey, + @Nonnull final ItagItem.ItagType itagTypeWanted, + @Nullable final String contentPlaybackNonce) { + if (streamingData == null || !streamingData.has(streamingDataKey)) { + return java.util.stream.Stream.empty(); + } + + final String preferredAudioLanguage = ServiceList.YouTube.getAudioLanguage(); + final String cpn = isNullOrEmpty(contentPlaybackNonce) + ? generateContentPlaybackNonce() : contentPlaybackNonce; + + return streamingData.getArray(streamingDataKey).stream() + .filter(JsonObject.class::isInstance) + .map(JsonObject.class::cast) + .map(formatData -> { + try { + final ItagItem itagItem = ItagItem.getItag(formatData.getInt("itag")); + if (itagItem.itagType != itagTypeWanted) { + return null; + } + + final ItagInfo itagInfo = buildItagInfo(videoId, formatData, itagItem, + itagItem.itagType, cpn); + if (itagInfo != null && itagItem.itagType == ItagItem.ItagType.AUDIO) { + extractAndSetAudioTrackInfo(formatData, itagInfo, preferredAudioLanguage); + } + return itagInfo; + } catch (final Exception e) { + return null; + } + }) + .filter(Objects::nonNull); + } + + private void extractAndSetAudioTrackInfo(final JsonObject formatData, + final ItagInfo itagInfo, + final String preferredAudioLanguage) { + if (!formatData.has("audioTrack")) { + return; + } + final JsonObject audioTrack = formatData.getObject("audioTrack"); + if (!audioTrack.has("id")) { + return; + } + + final String audioTrackId = audioTrack.getString("id"); + final String displayName = audioTrack.getString("displayName", EMPTY_STRING); + final String langPart = audioTrackId.split("\\.")[0]; + final String audioLocale = langPart.split("-")[0]; + final boolean isDefault = displayName.contains("original") + || displayName.contains("yokuqala") + || langPart.equals(preferredAudioLanguage); + final String audioTrackName = isDefault ? langPart + " (original)" : langPart; + itagInfo.setAudioTrackInfo(audioTrackId, audioTrackName, audioLocale); + } + + @Nullable + private ItagInfo buildItagInfo(@Nonnull final String videoId, + @Nonnull final JsonObject formatData, + @Nonnull final ItagItem itagItem, + @Nonnull final ItagItem.ItagType itagType, + @Nonnull final String contentPlaybackNonce) + throws IOException, ExtractionException { + String streamUrl; + String obfuscatedSignature = null; + + if (formatData.has("url")) { + streamUrl = formatData.getString("url"); + } else if (formatData.has(SIGNATURE_CIPHER) || formatData.has(CIPHER)) { + final String cipherString = formatData.getString(CIPHER, + formatData.getString(SIGNATURE_CIPHER)); + final Map cipher = Parser.compatParseMap(cipherString); + obfuscatedSignature = cipher.getOrDefault("s", ""); + streamUrl = cipher.get("url") + "&" + cipher.get("sp") + "=SIGNATURE_PLACEHOLDER"; + } else { + return null; + } + + streamUrl += "&" + CPN + "=" + contentPlaybackNonce; + + final JsonObject initRange = formatData.getObject("initRange"); + final JsonObject indexRange = formatData.getObject("indexRange"); + final String mimeType = formatData.getString("mimeType", EMPTY_STRING); + final String codec = mimeType.contains("codecs") ? mimeType.split("\"")[1] : EMPTY_STRING; + final int fps = formatData.getInt("fps", -1); + + itagItem.setBitrate(formatData.getInt("bitrate")); + itagItem.setWidth(formatData.getInt("width")); + itagItem.setHeight(formatData.getInt("height")); + if (initRange != null) { + itagItem.setInitStart(Integer.parseInt(initRange.getString("start", "-1"))); + itagItem.setInitEnd(Integer.parseInt(initRange.getString("end", "-1"))); + } + if (indexRange != null) { + itagItem.setIndexStart(Integer.parseInt(indexRange.getString("start", "-1"))); + itagItem.setIndexEnd(Integer.parseInt(indexRange.getString("end", "-1"))); + } + itagItem.setQuality(formatData.getString("quality")); + itagItem.setCodec(codec); + + if (streamType == StreamType.LIVE_STREAM || streamType == StreamType.POST_LIVE_STREAM) { + itagItem.setTargetDurationSec(formatData.getInt("targetDurationSec")); + } else if ((itagType == ItagItem.ItagType.VIDEO + || itagType == ItagItem.ItagType.VIDEO_ONLY) && fps != -1) { + itagItem.setFps(fps); + } else if (itagType == ItagItem.ItagType.AUDIO) { + if (formatData.has("audioSampleRate")) { + itagItem.setSampleRate(Integer.parseInt(formatData.getString("audioSampleRate"))); + } + itagItem.setAudioChannels(formatData.getInt("audioChannels", 2)); + } + + itagItem.setContentLength(Long.parseLong(formatData.getString("contentLength", + String.valueOf(CONTENT_LENGTH_UNKNOWN)))); + itagItem.setApproxDurationMs(Long.parseLong(formatData.getString("approxDurationMs", + String.valueOf(APPROX_DURATION_MS_UNKNOWN)))); + + final ItagInfo itagInfo = new ItagInfo(streamUrl, itagItem); + itagInfo.setObfuscatedSignature(obfuscatedSignature); + itagInfo.setIsUrl(streamType == StreamType.VIDEO_STREAM + ? !formatData.getString("type", EMPTY_STRING) + .equalsIgnoreCase("FORMAT_STREAM_TYPE_OTF") + : streamType != StreamType.POST_LIVE_STREAM); + return itagInfo; + } + + private void batchDeobfuscateItagUrls(@Nonnull final String videoId, + @Nonnull final List itagInfoList) + throws ParsingException { + if (itagInfoList.isEmpty()) { + return; + } + + final LinkedHashSet uniqueSignatures = new LinkedHashSet<>(); + final LinkedHashSet uniqueThrottlingParams = new LinkedHashSet<>(); + final List streamInfos = new ArrayList<>(); + + for (int i = 0; i < itagInfoList.size(); i++) { + final ItagInfo itagInfo = itagInfoList.get(i); + final String url = itagInfo.getContent(); + final String obfuscatedSignature = itagInfo.getObfuscatedSignature(); + final String throttlingParam = + YoutubeJavaScriptPlayerManager.getThrottlingParameterFromStreamingUrl(url); + + streamInfos.add(new StreamDeobfuscationInfo(i, obfuscatedSignature, throttlingParam)); + if (!isNullOrEmpty(obfuscatedSignature)) { + uniqueSignatures.add(obfuscatedSignature); + } + if (!isNullOrEmpty(throttlingParam)) { + uniqueThrottlingParams.add(throttlingParam); + } + } + + if (uniqueSignatures.isEmpty() && uniqueThrottlingParams.isEmpty()) { + return; + } + + final YoutubeApiDecoder.BatchDecodeResult result = + YoutubeJavaScriptPlayerManager.deobfuscateBatch( + videoId, + new ArrayList<>(uniqueSignatures), + new ArrayList<>(uniqueThrottlingParams)); + + final Map decodedSignatures = result.getSignatures(); + final Map decodedThrottling = result.getNParameters(); + + for (final StreamDeobfuscationInfo info : streamInfos) { + final ItagInfo itagInfo = itagInfoList.get(info.streamIndex); + String updatedUrl = itagInfo.getContent(); + + if (!isNullOrEmpty(info.obfuscatedSignature)) { + final String deobfuscatedSig = decodedSignatures.get(info.obfuscatedSignature); + if (deobfuscatedSig != null) { + updatedUrl = updatedUrl.replace("SIGNATURE_PLACEHOLDER", deobfuscatedSig); + } + } + + if (!isNullOrEmpty(info.throttlingParam)) { + final String deobfuscatedParam = decodedThrottling.get(info.throttlingParam); + if (deobfuscatedParam != null) { + updatedUrl = updatedUrl.replace(info.throttlingParam, deobfuscatedParam); + } + } + + itagInfo.setContent(updatedUrl); + } + } + + private static class StreamDeobfuscationInfo { + private final int streamIndex; + @Nullable + private final String obfuscatedSignature; + @Nullable + private final String throttlingParam; + + StreamDeobfuscationInfo(final int streamIndex, + @Nullable final String obfuscatedSignature, + @Nullable final String throttlingParam) { + this.streamIndex = streamIndex; + this.obfuscatedSignature = obfuscatedSignature; + this.throttlingParam = throttlingParam; + } + } + /*////////////////////////////////////////////////////////////////////////// // Utils