diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeJavaScriptExtractor.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeJavaScriptExtractor.java new file mode 100644 index 000000000..7b8d83dd6 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeJavaScriptExtractor.java @@ -0,0 +1,112 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.utils.Parser; + +import javax.annotation.Nonnull; + +/** + * YouTube restricts streaming their media in multiple ways by requiring clients to apply a cipher + * function on parameters of requests. + * The cipher function is sent alongside as a JavaScript function. + *

+ * This class handling fetching the JavaScript file in order to allow other classes to extract the + * needed functions. + */ +public class YoutubeJavaScriptExtractor { + + private static final String HTTPS = "https:"; + private static String cachedJavaScriptCode; + + private YoutubeJavaScriptExtractor() { + } + + /** + * Extracts the JavaScript file. The result is cached, so subsequent calls use the result of + * previous calls. + * + * @param videoId Does not influence the result, but a valid video id may help in the chance + * that YouTube tracks it. + * @return The whole JavaScript file as a string. + * @throws ParsingException If the extraction failed. + */ + @Nonnull + public static String extractJavaScriptCode(final String videoId) throws ParsingException { + if (cachedJavaScriptCode == null) { + final String playerJsUrl = YoutubeJavaScriptExtractor.cleanJavaScriptUrl( + YoutubeJavaScriptExtractor.extractJavaScriptUrl(videoId)); + cachedJavaScriptCode = YoutubeJavaScriptExtractor.downloadJavaScriptCode(playerJsUrl); + } + + return cachedJavaScriptCode; + } + + /** + * Same as {@link YoutubeJavaScriptExtractor#extractJavaScriptCode(String)} but with a constant + * value for videoId. + * Possible because the videoId has no influence on the result. + *

+ * In the off chance that YouTube tracks with which video id the request is made, it may make + * sense to pass in video ids. + */ + @Nonnull + public static String extractJavaScriptCode() throws ParsingException { + return extractJavaScriptCode("d4IGg5dqeO8"); + } + + private static String extractJavaScriptUrl(final String videoId) throws ParsingException { + try { + final String embedUrl = "https://www.youtube.com/embed/" + videoId; + final String embedPageContent = NewPipe.getDownloader() + .get(embedUrl, Localization.DEFAULT).responseBody(); + + try { + final String assetsPattern = "\"assets\":.+?\"js\":\\s*(\"[^\"]+\")"; + return Parser.matchGroup1(assetsPattern, embedPageContent) + .replace("\\", "").replace("\"", ""); + } catch (final Parser.RegexException ex) { + // 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 Elements elems = doc.select("script").attr("name", "player_ias/base"); + for (final Element elem : elems) { + if (elem.attr("src").contains("base.js")) { + return elem.attr("src"); + } + } + } + + } catch (final Exception i) { + throw new ParsingException("Embedded info did not provide YouTube player js url"); + } + throw new ParsingException("Embedded info did not provide YouTube player js url"); + } + + @Nonnull + private static String cleanJavaScriptUrl(@Nonnull final String playerJsUrl) { + if (playerJsUrl.startsWith("//")) { + return HTTPS + playerJsUrl; + } else if (playerJsUrl.startsWith("/")) { + // sometimes https://www.youtube.com part has to be added manually + return HTTPS + "//www.youtube.com" + playerJsUrl; + } else { + return playerJsUrl; + } + } + + @Nonnull + private static String downloadJavaScriptCode(final String playerJsUrl) + throws ParsingException { + try { + return NewPipe.getDownloader().get(playerJsUrl, Localization.DEFAULT).responseBody(); + } catch (final Exception e) { + throw new ParsingException("Could not get player js code from url: " + playerJsUrl); + } + } +} diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeThrottlingDecrypter.java b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeThrottlingDecrypter.java new file mode 100644 index 000000000..4f10e8c58 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeThrottlingDecrypter.java @@ -0,0 +1,126 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.utils.JavaScript; +import org.schabi.newpipe.extractor.utils.Parser; + +import javax.annotation.Nonnull; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +/** + *

+ * YouTube's media is protected with a cipher, + * which modifies the "n" query parameter of it's video playback urls. + * This class handles extracting that "n" query parameter, + * applying the cipher on it and returning the resulting url which is not throttled. + *

+ * + *

+ * https://r5---sn-4g5ednsz.googlevideo.com/videoplayback?n=VVF2xyZLVRZZxHXZ&other=other + *

+ * becomes + *

+ * https://r5---sn-4g5ednsz.googlevideo.com/videoplayback?n=iHywZkMipkszqA&other=other + *

+ *
+ *

+ * Decoding the "n" parameter is time intensive. For this reason, the results are cached. + * The cache can be cleared using {@link #clearCache()} + *

+ * + */ +public class YoutubeThrottlingDecrypter { + + private static final String N_PARAM_REGEX = "[&?]n=([^&]+)"; + private static final Map nParams = new HashMap<>(); + + private final String functionName; + private final String function; + + /** + *

+ * Use this if you care about the off chance that YouTube tracks with which videoId the cipher + * is requested. + *

+ * Otherwise use the no-arg constructor which uses a constant value. + */ + public YoutubeThrottlingDecrypter(final String videoId) throws ParsingException { + final String playerJsCode = YoutubeJavaScriptExtractor.extractJavaScriptCode(videoId); + + functionName = parseDecodeFunctionName(playerJsCode); + function = parseDecodeFunction(playerJsCode, functionName); + } + + public YoutubeThrottlingDecrypter() throws ParsingException { + final String playerJsCode = YoutubeJavaScriptExtractor.extractJavaScriptCode(); + + functionName = parseDecodeFunctionName(playerJsCode); + function = parseDecodeFunction(playerJsCode, functionName); + } + + private String parseDecodeFunctionName(final String playerJsCode) + throws Parser.RegexException { + Pattern pattern = Pattern.compile( + "b=a\\.get\\(\"n\"\\)\\)&&\\(b=(\\w+)\\(b\\),a\\.set\\(\"n\",b\\)"); + return Parser.matchGroup1(pattern, playerJsCode); + } + + @Nonnull + private String parseDecodeFunction(final String playerJsCode, final String functionName) + throws Parser.RegexException { + Pattern functionPattern = Pattern.compile(functionName + "=function(.*?;)\n", + Pattern.DOTALL); + return "function " + functionName + Parser.matchGroup1(functionPattern, playerJsCode); + } + + public String apply(final String url) throws Parser.RegexException { + if (containsNParam(url)) { + String oldNParam = parseNParam(url); + String newNParam = decryptNParam(oldNParam); + return replaceNParam(url, oldNParam, newNParam); + } else { + return url; + } + } + + private boolean containsNParam(final String url) { + return Parser.isMatch(N_PARAM_REGEX, url); + } + + private String parseNParam(final String url) throws Parser.RegexException { + Pattern nValuePattern = Pattern.compile(N_PARAM_REGEX); + return Parser.matchGroup1(nValuePattern, url); + } + + private String decryptNParam(final String nParam) { + if (nParams.containsKey(nParam)) { + return nParams.get(nParam); + } + final String decryptedNParam = JavaScript.run(function, functionName, nParam); + nParams.put(nParam, decryptedNParam); + return decryptedNParam; + } + + @Nonnull + private String replaceNParam(@Nonnull final String url, + final String oldValue, + final String newValue) { + return url.replace(oldValue, newValue); + } + + /** + * @return the number of the cached "n" query parameters. + */ + public static int getCacheSize() { + return nParams.size(); + } + + /** + * Clears all stored "n" query parameters. + */ + public static void clearCache() { + nParams.clear(); + } +} 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 8cc60b24d..53d9449e5 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 @@ -4,10 +4,6 @@ import com.grack.nanojson.JsonArray; import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonParser; import com.grack.nanojson.JsonParserException; -import org.jsoup.Jsoup; -import org.jsoup.nodes.Document; -import org.jsoup.nodes.Element; -import org.jsoup.select.Elements; import org.mozilla.javascript.Context; import org.mozilla.javascript.Function; import org.mozilla.javascript.ScriptableObject; @@ -24,7 +20,9 @@ import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.localization.TimeAgoParser; import org.schabi.newpipe.extractor.localization.TimeAgoPatternsManager; import org.schabi.newpipe.extractor.services.youtube.ItagItem; +import org.schabi.newpipe.extractor.services.youtube.YoutubeJavaScriptExtractor; import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; +import org.schabi.newpipe.extractor.services.youtube.YoutubeThrottlingDecrypter; import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeChannelLinkHandlerFactory; import org.schabi.newpipe.extractor.stream.*; import org.schabi.newpipe.extractor.utils.JsonUtils; @@ -79,13 +77,10 @@ public class YoutubeStreamExtractor extends StreamExtractor { @Nullable private static String cachedDeobfuscationCode = null; - @Nullable - private String playerJsUrl = null; - - private JsonArray initialAjaxJson; - private JsonObject initialData; @Nonnull private final Map videoInfoPage = new HashMap<>(); + private JsonArray initialAjaxJson; + private JsonObject initialData; private JsonObject playerResponse; private JsonObject videoPrimaryInfoRenderer; private JsonObject videoSecondaryInfoRenderer; @@ -505,11 +500,15 @@ public class YoutubeStreamExtractor extends StreamExtractor { public List getAudioStreams() throws ExtractionException { assertPageFetched(); final List audioStreams = new ArrayList<>(); + final YoutubeThrottlingDecrypter throttlingDecrypter = new YoutubeThrottlingDecrypter(getId()); try { for (final Map.Entry entry : getItags(ADAPTIVE_FORMATS, ItagItem.ItagType.AUDIO).entrySet()) { final ItagItem itag = entry.getValue(); - final AudioStream audioStream = new AudioStream(entry.getKey(), itag); + String url = entry.getKey(); + url = throttlingDecrypter.apply(url); + + final AudioStream audioStream = new AudioStream(url, itag); if (!Stream.containSimilarStream(audioStream, audioStreams)) { audioStreams.add(audioStream); } @@ -525,11 +524,15 @@ public class YoutubeStreamExtractor extends StreamExtractor { public List getVideoStreams() throws ExtractionException { assertPageFetched(); final List videoStreams = new ArrayList<>(); + final YoutubeThrottlingDecrypter throttlingDecrypter = new YoutubeThrottlingDecrypter(getId()); try { for (final Map.Entry entry : getItags(FORMATS, ItagItem.ItagType.VIDEO).entrySet()) { final ItagItem itag = entry.getValue(); - final VideoStream videoStream = new VideoStream(entry.getKey(), false, itag); + String url = entry.getKey(); + url = throttlingDecrypter.apply(url); + + final VideoStream videoStream = new VideoStream(url, false, itag); if (!Stream.containSimilarStream(videoStream, videoStreams)) { videoStreams.add(videoStream); } @@ -545,11 +548,15 @@ public class YoutubeStreamExtractor extends StreamExtractor { public List getVideoOnlyStreams() throws ExtractionException { assertPageFetched(); final List videoOnlyStreams = new ArrayList<>(); + final YoutubeThrottlingDecrypter throttlingDecrypter = new YoutubeThrottlingDecrypter(getId()); + try { for (final Map.Entry entry : getItags(ADAPTIVE_FORMATS, ItagItem.ItagType.VIDEO_ONLY).entrySet()) { final ItagItem itag = entry.getValue(); + String url = entry.getKey(); + url = throttlingDecrypter.apply(url); - final VideoStream videoStream = new VideoStream(entry.getKey(), true, itag); + final VideoStream videoStream = new VideoStream(url, true, itag); if (!Stream.containSimilarStream(videoStream, videoOnlyStreams)) { videoOnlyStreams.add(videoStream); } @@ -797,38 +804,6 @@ public class YoutubeStreamExtractor extends StreamExtractor { } } - @Nonnull - private String getEmbeddedInfoStsAndStorePlayerJsUrl() { - try { - final String embedUrl = "https://www.youtube.com/embed/" + getId(); - final String embedPageContent = NewPipe.getDownloader() - .get(embedUrl, getExtractorLocalization()).responseBody(); - - try { - final String assetsPattern = "\"assets\":.+?\"js\":\\s*(\"[^\"]+\")"; - playerJsUrl = Parser.matchGroup1(assetsPattern, embedPageContent) - .replace("\\", "").replace("\"", ""); - } catch (final Parser.RegexException ex) { - // 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 Elements elems = doc.select("script").attr("name", "player_ias/base"); - for (final Element elem : elems) { - if (elem.attr("src").contains("base.js")) { - playerJsUrl = elem.attr("src"); - break; - } - } - } - - // Get embed sts - return Parser.matchGroup1("\"sts\"\\s*:\\s*(\\d+)", embedPageContent); - } catch (final Exception i) { - // if it fails we simply reply with no sts as then it does not seem to be necessary - return ""; - } - } - private String getDeobfuscationFuncName(final String playerCode) throws DeobfuscateException { Parser.RegexException exception = null; for (final String regex : REGEXES) { @@ -843,11 +818,10 @@ public class YoutubeStreamExtractor extends StreamExtractor { throw new DeobfuscateException("Could not find deobfuscate function with any of the given patterns.", exception); } - private String loadDeobfuscationCode(@Nonnull final String playerJsUrl) + private String loadDeobfuscationCode() throws DeobfuscateException { try { - final String playerCode = NewPipe.getDownloader() - .get(playerJsUrl, getExtractorLocalization()).responseBody(); + final String playerCode = YoutubeJavaScriptExtractor.extractJavaScriptCode(getId()); final String deobfuscationFunctionName = getDeobfuscationFuncName(playerCode); final String functionPattern = "(" @@ -866,8 +840,6 @@ public class YoutubeStreamExtractor extends StreamExtractor { "function " + DEOBFUSCATION_FUNC_NAME + "(a){return " + deobfuscationFunctionName + "(a);}"; return helperObject + deobfuscateFunction + callerFunction; - } catch (final IOException ioe) { - throw new DeobfuscateException("Could not load deobfuscate function", ioe); } catch (final Exception e) { throw new DeobfuscateException("Could not parse deobfuscate function ", e); } @@ -876,24 +848,7 @@ public class YoutubeStreamExtractor extends StreamExtractor { @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://www.youtube.com part has to be added manually - playerJsUrl = HTTPS + "//www.youtube.com" + playerJsUrl; - } - - cachedDeobfuscationCode = loadDeobfuscationCode(playerJsUrl); + cachedDeobfuscationCode = loadDeobfuscationCode(); } return cachedDeobfuscationCode; } diff --git a/extractor/src/main/java/org/schabi/newpipe/extractor/utils/JavaScript.java b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/JavaScript.java new file mode 100644 index 000000000..4b81ba7d7 --- /dev/null +++ b/extractor/src/main/java/org/schabi/newpipe/extractor/utils/JavaScript.java @@ -0,0 +1,29 @@ +package org.schabi.newpipe.extractor.utils; + +import org.mozilla.javascript.Context; +import org.mozilla.javascript.Function; +import org.mozilla.javascript.ScriptableObject; + +public class JavaScript { + + private JavaScript() { + } + + public static String run(final String function, + final String functionName, + final String... parameters) { + try { + final Context context = Context.enter(); + context.setOptimizationLevel(-1); + final ScriptableObject scope = context.initSafeStandardObjects(); + + context.evaluateString(scope, function, functionName, 1, null); + final Function jsFunction = (Function) scope.get(functionName, scope); + final Object result = jsFunction.call(context, scope, scope, parameters); + return result.toString(); + } finally { + Context.exit(); + } + } + +} diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeJavaScriptExtractorTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeJavaScriptExtractorTest.java new file mode 100644 index 000000000..f86dbf716 --- /dev/null +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeJavaScriptExtractorTest.java @@ -0,0 +1,47 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import org.junit.Before; +import org.junit.Test; +import org.schabi.newpipe.downloader.DownloaderTestImpl; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.exceptions.ParsingException; + +import java.io.IOException; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; + +public class YoutubeJavaScriptExtractorTest { + + @Before + public void setup() throws IOException { + NewPipe.init(DownloaderTestImpl.getInstance()); + } + + @Test + public void testExtractJavaScript__success() throws ParsingException { + String playerJsCode = YoutubeJavaScriptExtractor.extractJavaScriptCode("d4IGg5dqeO8"); + assertPlayerJsCode(playerJsCode); + + playerJsCode = YoutubeJavaScriptExtractor.extractJavaScriptCode(); + assertPlayerJsCode(playerJsCode); + } + + @Test + public void testExtractJavaScript__invalidVideoId__success() throws ParsingException { + String playerJsCode = YoutubeJavaScriptExtractor.extractJavaScriptCode("not_a_video_id"); + assertPlayerJsCode(playerJsCode); + + playerJsCode = YoutubeJavaScriptExtractor.extractJavaScriptCode("11-chars123"); + assertPlayerJsCode(playerJsCode); + + } + + private void assertPlayerJsCode(final String playerJsCode) { + assertThat(playerJsCode, allOf( + containsString(" Copyright The Closure Library Authors.\n" + + " SPDX-License-Identifier: Apache-2.0"), + containsString("var _yt_player"))); + } +} \ No newline at end of file diff --git a/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeThrottlingDecrypterTest.java b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeThrottlingDecrypterTest.java new file mode 100644 index 000000000..f072615f6 --- /dev/null +++ b/extractor/src/test/java/org/schabi/newpipe/extractor/services/youtube/YoutubeThrottlingDecrypterTest.java @@ -0,0 +1,39 @@ +package org.schabi.newpipe.extractor.services.youtube; + +import org.junit.Before; +import org.junit.Test; +import org.schabi.newpipe.downloader.DownloaderTestImpl; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.exceptions.ParsingException; + +import java.io.IOException; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNotEquals; + +public class YoutubeThrottlingDecrypterTest { + + @Before + public void setup() throws IOException { + NewPipe.init(DownloaderTestImpl.getInstance()); + } + + @Test + public void testDecode__success() throws ParsingException { + // URL extracted from browser with the dev tools + final String encryptedUrl = "https://r6---sn-4g5ednek.googlevideo.com/videoplayback?expire=1626562120&ei=6AnzYO_YBpql1gLGkb_IBQ&ip=127.0.0.1&id=o-ANhBEf36Z5h-8U9DDddtPDqtS0ZNwf0XJAAigudKI2uI&itag=278&aitags=133%2C134%2C135%2C136%2C137%2C160%2C242%2C243%2C244%2C247%2C248%2C278&source=youtube&requiressl=yes&vprv=1&mime=video%2Fwebm&ns=TvecOReN0vPuXb3j_zq157IG&gir=yes&clen=2915100&dur=270.203&lmt=1608157174907785&keepalive=yes&fexp=24001373,24007246&c=WEB&txp=5535432&n=N9BWSTFT7vvBJrvQ&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&alr=yes&sig=AOq0QJ8wRQIgW6XnUDKPDSxiT0_KE_tDDMpcaCJl2Un5p0Fu9qZNQGkCIQDWxsDHi_s2BEmRqIbd1C5g_gzfihB7RZLsScKWNMwzzA%3D%3D&cpn=9r2yt3BqcYmeb2Yu&cver=2.20210716.00.00&redirect_counter=1&cm2rm=sn-4g5ezy7s&cms_redirect=yes&mh=Y5&mm=34&mn=sn-4g5ednek&ms=ltu&mt=1626540524&mv=m&mvi=6&pl=43&lsparams=mh,mm,mn,ms,mv,mvi,pl&lsig=AG3C_xAwRQIhAIUzxTn9Vw1-vm-_7OQ5-0h1M6AZsY9Bx1FlCCTeMICzAiADtGggbn4Znsrh2EnvyOsGnYdRGcbxn4mW9JMOQiInDQ%3D%3D&range=259165-480735&rn=11&rbuf=20190"; + final String decryptedUrl = new YoutubeThrottlingDecrypter().apply(encryptedUrl); + // The cipher function changes over time, so we just check if the n param changed. + assertNotEquals(encryptedUrl, decryptedUrl); + } + + @Test + public void testDecode__noNParam__success() throws ParsingException { + final String noNParamUrl = "https://r5---sn-4g5ednsz.googlevideo.com/videoplayback?expire=1626553257&ei=SefyYPmIFoKT1wLtqbjgCQ&ip=127.0.0.1&id=o-AIT5xGifsaEAdEOAb5vd06J9VNtm-KHHolnaZRGPjHZi&itag=140&source=youtube&requiressl=yes&mh=xO&mm=31%2C29&mn=sn-4g5ednsz%2Csn-4g5e6nsr&ms=au%2Crdu&mv=m&mvi=5&pl=24&initcwndbps=1322500&vprv=1&mime=audio%2Fmp4&ns=cA2SS5atEe0mH8tMwGTO4RIG&gir=yes&clen=3009275&dur=185.898&lmt=1626356984653961&mt=1626531173&fvip=5&keepalive=yes&fexp=24001373%2C24007246&beids=23886212&c=WEB&txp=6411222&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAPueRlTutSlzPafxrqBmgZz5m7-Zfbw3QweDp3j4XO9SAiEA5tF7_ZCJFKmS-D6I1jlUURjpjoiTbsYyKuarV4u6E8Y%3D&sig=AOq0QJ8wRQIgRD_4WwkPeTEKGVSQqPsznMJGqq4nVJ8o1ChGBCgi4Y0CIQCZT3tI40YLKBWJCh2Q7AlvuUIpN0ficzdSElLeQpJdrw=="; + String decrypted = new YoutubeThrottlingDecrypter().apply(noNParamUrl); + + assertThat(decrypted, equalTo(noNParamUrl)); + } + +} \ No newline at end of file