NewPipeExtractor/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeThrottlingDecrypter....

201 lines
7.7 KiB
Java

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 org.schabi.newpipe.extractor.utils.jsextractor.JavaScriptExtractor;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nonnull;
/**
* YouTube's streaming URLs of HTML5 clients are protected with a cipher, which modifies their
* {@code n} query parameter.
*
* <p>
* This class handles extracting that {@code n} query parameter, applying the cipher on it and
* returning the resulting URL which is not throttled.
* </p>
*
* <p>
* For instance,
* {@code https://r5---sn-4g5ednsz.googlevideo.com/videoplayback?n=VVF2xyZLVRZZxHXZ&other=other}
* becomes
* {@code https://r5---sn-4g5ednsz.googlevideo.com/videoplayback?n=iHywZkMipkszqA&other=other}.
* </p>
*
* <p>
* Decoding the {@code n} parameter is time intensive. For this reason, the results are cached.
* The cache can be cleared using {@link #clearCache()}.
* </p>
*
*/
public final class YoutubeThrottlingDecrypter {
private static final Pattern N_PARAM_PATTERN = Pattern.compile("[&?]n=([^&]+)");
private static final Pattern DECRYPT_FUNCTION_NAME_PATTERN = Pattern.compile(
// CHECKSTYLE:OFF
"\\.get\\(\"n\"\\)\\)&&\\([a-zA-Z0-9$_]=([a-zA-Z0-9$_]+)(?:\\[(\\d+)])?\\([a-zA-Z0-9$_]\\)");
// CHECKSTYLE:ON
// Escape the curly end brace to allow compatibility with Android's regex engine
// See https://stackoverflow.com/q/45074813
@SuppressWarnings("RegExpRedundantEscape")
private static final String DECRYPT_FUNCTION_BODY_REGEX =
"=\\s*function([\\S\\s]*?\\}\\s*return [\\w$]+?\\.join\\(\"\"\\)\\s*\\};)";
private static final Map<String, String> N_PARAMS_CACHE = new HashMap<>();
private static String decryptFunction;
private static String decryptFunctionName;
private YoutubeThrottlingDecrypter() {
// No implementation
}
/**
* Try to decrypt a YouTube streaming URL protected with a throttling parameter.
*
* <p>
* If the streaming URL provided doesn't contain a throttling parameter, it is returned as it
* is; otherwise, the encrypted value is decrypted and this value is replaced by the decrypted
* one.
* </p>
*
* <p>
* If the JavaScript code has been not extracted, it is extracted with the given video ID using
* {@link YoutubeJavaScriptExtractor#extractJavaScriptCode(String)}.
* </p>
*
* @param streamingUrl The streaming URL to decrypt, if needed.
* @param videoId A video ID, used to fetch the JavaScript code to get the decryption
* function. It can be a constant value of any existing video, but a
* constant value is discouraged, because it could allow tracking.
* @return A streaming URL with the decrypted parameter or the streaming URL itself if no
* throttling parameter has been found.
* @throws ParsingException If the streaming URL contains a throttling parameter and its
* decryption failed
*/
public static String apply(@Nonnull final String streamingUrl,
@Nonnull final String videoId) throws ParsingException {
if (!containsNParam(streamingUrl)) {
return streamingUrl;
}
try {
if (decryptFunction == null) {
final String playerJsCode
= YoutubeJavaScriptExtractor.extractJavaScriptCode(videoId);
decryptFunctionName = parseDecodeFunctionName(playerJsCode);
decryptFunction = parseDecodeFunction(playerJsCode, decryptFunctionName);
}
final String oldNParam = parseNParam(streamingUrl);
final String newNParam = decryptNParam(decryptFunction, decryptFunctionName, oldNParam);
return replaceNParam(streamingUrl, oldNParam, newNParam);
} catch (final Exception e) {
throw new ParsingException("Could not parse, decrypt or replace n parameter", e);
}
}
private static String parseDecodeFunctionName(final String playerJsCode)
throws Parser.RegexException {
final Matcher matcher = DECRYPT_FUNCTION_NAME_PATTERN.matcher(playerJsCode);
if (!matcher.find()) {
throw new Parser.RegexException("Failed to find pattern \""
+ DECRYPT_FUNCTION_NAME_PATTERN + "\"");
}
final String functionName = matcher.group(1);
if (matcher.groupCount() == 1) {
return functionName;
}
final int arrayNum = Integer.parseInt(matcher.group(2));
final Pattern arrayPattern = Pattern.compile(
"var " + Pattern.quote(functionName) + "\\s*=\\s*\\[(.+?)];");
final String arrayStr = Parser.matchGroup1(arrayPattern, playerJsCode);
final String[] names = arrayStr.split(",");
return names[arrayNum];
}
@Nonnull
private static String parseDecodeFunction(final String playerJsCode, final String functionName)
throws Parser.RegexException {
try {
return parseWithLexer(playerJsCode, functionName);
} catch (final Exception e) {
return parseWithRegex(playerJsCode, functionName);
}
}
@Nonnull
private static String parseWithRegex(final String playerJsCode, final String functionName)
throws Parser.RegexException {
// Quote the function name, as it may contain special regex characters such as dollar
final Pattern functionPattern = Pattern.compile(
Pattern.quote(functionName) + DECRYPT_FUNCTION_BODY_REGEX, Pattern.DOTALL);
return validateFunction("function "
+ functionName
+ Parser.matchGroup1(functionPattern, playerJsCode));
}
@Nonnull
private static String validateFunction(@Nonnull final String function) {
JavaScript.compileOrThrow(function);
return function;
}
@Nonnull
private static String parseWithLexer(final String playerJsCode, final String functionName)
throws ParsingException {
final String functionBase = functionName + "=function";
return functionBase + JavaScriptExtractor.matchToClosingBrace(playerJsCode, functionBase)
+ ";";
}
private static boolean containsNParam(final String url) {
return Parser.isMatch(N_PARAM_PATTERN, url);
}
private static String parseNParam(final String url) throws Parser.RegexException {
return Parser.matchGroup1(N_PARAM_PATTERN, url);
}
private static String decryptNParam(final String function,
final String functionName,
final String nParam) {
if (N_PARAMS_CACHE.containsKey(nParam)) {
return N_PARAMS_CACHE.get(nParam);
}
final String decryptedNParam = JavaScript.run(function, functionName, nParam);
N_PARAMS_CACHE.put(nParam, decryptedNParam);
return decryptedNParam;
}
@Nonnull
private static String replaceNParam(@Nonnull final String url,
final String oldValue,
final String newValue) {
return url.replace(oldValue, newValue);
}
/**
* @return The number of the cached {@code n} query parameters.
*/
public static int getCacheSize() {
return N_PARAMS_CACHE.size();
}
/**
* Clears all stored {@code n} query parameters.
*/
public static void clearCache() {
N_PARAMS_CACHE.clear();
}
}