Skip to content

Commit 05b7fee

Browse files
committed
[YouTube] Add the cpn param to playback requests and try to spoof better the Android client
The cpn param, aka the content playback nonce param, is a parameter sent by YouTube web client in videoplayback requests, and for some of them, in the player request body. This PR adds it everywhere. For the desktop/WEB client, some params were missing from the playbackContext object, which seemed (or not) to make YouTube throttle streams extracted from the WEB client. This PR adds them. Fingerprinting on the WEB client basing on the client version used is not possible anymore, because the latest client version is extracted at the first time of a YouTube request on a session which require the extractor to fetch again the website (and this may come back the reCaptcha issues again unfortunately, but it seems there is no other way to get it). For the Android client, the video id is now also sent as a query parameter, like a 12 characters string, in the t query parameter, in order to spoof better this client. Researches need to be done on this parameter, unique to each request, and how it is generated by clients. This commit also fixes a small bug with the Android User-Agent string. Some code improvements have been also made.
1 parent 83f374b commit 05b7fee

2 files changed

Lines changed: 241 additions & 238 deletions

File tree

extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java

Lines changed: 121 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import com.grack.nanojson.JsonWriter;
1616

1717
import org.schabi.newpipe.extractor.MetaInfo;
18-
import org.schabi.newpipe.extractor.Page;
1918
import org.schabi.newpipe.extractor.downloader.Response;
2019
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException;
2120
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
@@ -35,6 +34,8 @@
3534
import java.net.MalformedURLException;
3635
import java.net.URL;
3736
import java.net.URLDecoder;
37+
import java.nio.charset.StandardCharsets;
38+
import java.security.SecureRandom;
3839
import java.time.LocalDate;
3940
import java.time.OffsetDateTime;
4041
import java.time.ZoneOffset;
@@ -78,11 +79,15 @@ private YoutubeParsingHelper() {
7879
}
7980

8081
public static final String YOUTUBEI_V1_URL = "https://www.youtube.com/youtubei/v1/";
82+
public static final String CPN = "cpn";
83+
public static final String VIDEO_ID = "videoId";
8184

8285
private static final String HARDCODED_CLIENT_VERSION = "2.20220107.00.00";
8386
private static final String HARDCODED_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8";
84-
private static final String MOBILE_YOUTUBE_KEY = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w";
87+
88+
private static final String ANDROID_YOUTUBE_KEY = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w";
8589
private static final String MOBILE_YOUTUBE_CLIENT_VERSION = "16.49.37";
90+
8691
private static String clientVersion;
8792
private static String key;
8893

@@ -94,6 +99,9 @@ private YoutubeParsingHelper() {
9499
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
95100
private static Optional<Boolean> hardcodedClientVersionAndKeyValid = Optional.empty();
96101

102+
private static final String CONTENT_PLAYBACK_NONCE_ALPHABET =
103+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
104+
97105
private static Random numberGenerator = new Random();
98106

99107
/**
@@ -593,7 +601,7 @@ public static String getKey() throws IOException, ExtractionException {
593601

594602
// The ANDROID API key is also valid with the WEB client so return it if we couldn't
595603
// extract the WEB API key.
596-
return MOBILE_YOUTUBE_KEY;
604+
return ANDROID_YOUTUBE_KEY;
597605
}
598606

599607
/**
@@ -769,7 +777,7 @@ public static String getUrlFromNavigationEndpoint(@Nonnull final JsonObject navi
769777
} else if (navigationEndpoint.has("watchEndpoint")) {
770778
final StringBuilder url = new StringBuilder();
771779
url.append("https://www.youtube.com/watch?v=").append(navigationEndpoint
772-
.getObject("watchEndpoint").getString("videoId"));
780+
.getObject("watchEndpoint").getString(VIDEO_ID));
773781
if (navigationEndpoint.getObject("watchEndpoint").has("playlistId")) {
774782
url.append("&list=").append(navigationEndpoint.getObject("watchEndpoint")
775783
.getString("playlistId"));
@@ -906,17 +914,6 @@ public static String getValidJsonResponseBody(@Nonnull final Response response)
906914
return responseBody;
907915
}
908916

909-
public static Response getResponse(final String url, final Localization localization)
910-
throws IOException, ExtractionException {
911-
final Map<String, List<String>> headers = new HashMap<>();
912-
addYouTubeHeaders(headers);
913-
914-
final Response response = getDownloader().get(url, headers, localization);
915-
getValidJsonResponseBody(response);
916-
917-
return response;
918-
}
919-
920917
public static JsonObject getJsonPostResponse(final String endpoint,
921918
final byte[] body,
922919
final Localization localization)
@@ -931,46 +928,28 @@ public static JsonObject getJsonPostResponse(final String endpoint,
931928
return JsonUtils.toJsonObject(getValidJsonResponseBody(response));
932929
}
933930

934-
public static JsonObject getJsonMobilePostResponse(final String endpoint,
935-
final byte[] body,
936-
@Nonnull final ContentCountry
937-
contentCountry,
938-
final Localization localization)
939-
throws IOException, ExtractionException {
931+
public static JsonObject getJsonAndroidPostResponse(
932+
final String endpoint,
933+
final byte[] body,
934+
@Nonnull final ContentCountry contentCountry,
935+
final Localization localization,
936+
@Nullable final String endPartOfUrlRequest) throws IOException, ExtractionException {
940937
final Map<String, List<String>> headers = new HashMap<>();
941938
headers.put("Content-Type", Collections.singletonList("application/json"));
942939
// Spoofing an Android 11 device with the hardcoded version of the Android app
943940
headers.put("User-Agent", Collections.singletonList("com.google.android.youtube/"
944-
+ MOBILE_YOUTUBE_CLIENT_VERSION + "Linux; U; Android 11; "
941+
+ MOBILE_YOUTUBE_CLIENT_VERSION + " (Linux; U; Android 11; "
945942
+ contentCountry.getCountryCode() + ") gzip"));
946943
headers.put("x-goog-api-format-version", Collections.singletonList("2"));
947944

948-
final Response response = getDownloader().post(
949-
"https://youtubei.googleapis.com/youtubei/v1/" + endpoint + "?key="
950-
+ MOBILE_YOUTUBE_KEY, headers, body, localization);
951-
952-
return JsonUtils.toJsonObject(getValidJsonResponseBody(response));
953-
}
954-
955-
public static JsonArray getJsonResponse(final String url, final Localization localization)
956-
throws IOException, ExtractionException {
957-
final Map<String, List<String>> headers = new HashMap<>();
958-
addYouTubeHeaders(headers);
959-
960-
final Response response = getDownloader().get(url, headers, localization);
961-
962-
return JsonUtils.toJsonArray(getValidJsonResponseBody(response));
963-
}
945+
final String baseEndpointUrl = "https://youtubei.googleapis.com/youtubei/v1/" + endpoint
946+
+ "?key=" + ANDROID_YOUTUBE_KEY;
964947

965-
public static JsonArray getJsonResponse(@Nonnull final Page page,
966-
final Localization localization)
967-
throws IOException, ExtractionException {
968-
final Map<String, List<String>> headers = new HashMap<>();
969-
addYouTubeHeaders(headers);
948+
final Response response = getDownloader().post(isNullOrEmpty(endPartOfUrlRequest)
949+
? baseEndpointUrl : baseEndpointUrl + endPartOfUrlRequest,
950+
headers, body, localization);
970951

971-
final Response response = getDownloader().get(page.getUrl(), headers, localization);
972-
973-
return JsonUtils.toJsonArray(getValidJsonResponseBody(response));
952+
return JsonUtils.toJsonObject(getValidJsonResponseBody(response));
974953
}
975954

976955
@Nonnull
@@ -986,6 +965,13 @@ public static JsonBuilder<JsonObject> prepareDesktopJsonBuilder(
986965
.value("gl", contentCountry.getCountryCode())
987966
.value("clientName", "WEB")
988967
.value("clientVersion", getClientVersion())
968+
.value("originalUrl", "https://www.youtube.com")
969+
.value("platform", "DESKTOP")
970+
.end()
971+
.object("request")
972+
.array("internalExperimentFlags")
973+
.end()
974+
.value("useSsl", true)
989975
.end()
990976
.object("user")
991977
// TO DO: provide a way to enable restricted mode with:
@@ -1032,25 +1018,32 @@ public static JsonBuilder<JsonObject> prepareDesktopEmbedVideoJsonBuilder(
10321018
.value("clientName", "WEB")
10331019
.value("clientVersion", getClientVersion())
10341020
.value("clientScreen", "EMBED")
1021+
.value("originalUrl", "https://www.youtube.com")
1022+
.value("platform", "DESKTOP")
10351023
.end()
10361024
.object("thirdParty")
10371025
.value("embedUrl", "https://www.youtube.com/watch?v=" + videoId)
10381026
.end()
1027+
.object("request")
1028+
.array("internalExperimentFlags")
1029+
.end()
1030+
.value("useSsl", true)
1031+
.end()
10391032
.object("user")
10401033
// TO DO: provide a way to enable restricted mode with:
10411034
// .value("enableSafetyMode", boolean)
10421035
.value("lockedSafetyMode", false)
10431036
.end()
1044-
.end()
1045-
.value("videoId", videoId);
1037+
.end();
10461038
// @formatter:on
10471039
}
10481040

10491041
@Nonnull
10501042
public static JsonBuilder<JsonObject> prepareAndroidMobileEmbedVideoJsonBuilder(
10511043
@Nonnull final Localization localization,
10521044
@Nonnull final ContentCountry contentCountry,
1053-
@Nonnull final String videoId) {
1045+
@Nonnull final String videoId,
1046+
@Nonnull final String contentPlaybackNonce) {
10541047
// @formatter:off
10551048
return JsonObject.builder()
10561049
.object("context")
@@ -1064,48 +1057,53 @@ public static JsonBuilder<JsonObject> prepareAndroidMobileEmbedVideoJsonBuilder(
10641057
.object("thirdParty")
10651058
.value("embedUrl", "https://www.youtube.com/watch?v=" + videoId)
10661059
.end()
1060+
.object("request")
1061+
.array("internalExperimentFlags")
1062+
.end()
1063+
.value("useSsl", true)
1064+
.end()
10671065
.object("user")
10681066
// TO DO: provide a way to enable restricted mode with:
10691067
// .value("enableSafetyMode", boolean)
10701068
.value("lockedSafetyMode", false)
10711069
.end()
10721070
.end()
1073-
.value("videoId", videoId);
1071+
.value(CPN, contentPlaybackNonce)
1072+
.value(VIDEO_ID, videoId);
10741073
// @formatter:on
10751074
}
10761075

10771076
@Nonnull
1078-
public static byte[] createPlayerBodyWithSts(final Localization localization,
1079-
final ContentCountry contentCountry,
1080-
final String videoId,
1081-
final boolean withThirdParty,
1082-
@Nullable final String sts)
1083-
throws IOException, ExtractionException {
1084-
if (withThirdParty) {
1085-
// @formatter:off
1086-
return JsonWriter.string(prepareDesktopEmbedVideoJsonBuilder(
1087-
localization, contentCountry, videoId)
1088-
.object("playbackContext")
1089-
.object("contentPlaybackContext")
1090-
.value("signatureTimestamp", sts)
1091-
.end()
1092-
.end()
1093-
.done())
1094-
.getBytes(UTF_8);
1095-
// @formatter:on
1096-
} else {
1097-
// @formatter:off
1098-
return JsonWriter.string(prepareDesktopJsonBuilder(localization, contentCountry)
1099-
.value("videoId", videoId)
1100-
.object("playbackContext")
1101-
.object("contentPlaybackContext")
1102-
.value("signatureTimestamp", sts)
1103-
.end()
1077+
public static byte[] createDesktopPlayerBody(
1078+
@Nonnull final Localization localization,
1079+
@Nonnull final ContentCountry contentCountry,
1080+
@Nonnull final String videoId,
1081+
@Nonnull final String sts,
1082+
final boolean isEmbedClientScreen,
1083+
@Nonnull final String contentPlaybackNonce) throws IOException, ExtractionException {
1084+
// @formatter:off
1085+
return JsonWriter.string((isEmbedClientScreen
1086+
? prepareDesktopEmbedVideoJsonBuilder(localization, contentCountry,
1087+
videoId)
1088+
: prepareDesktopJsonBuilder(localization, contentCountry))
1089+
.object("playbackContext")
1090+
.object("contentPlaybackContext")
1091+
.value("currentUrl", "/watch?v=" + videoId)
1092+
.value("vis", 0)
1093+
.value("splay", false)
1094+
.value("autoCaptionsDefaultOn", false)
1095+
.value("autonavState", "STATE_NONE")
1096+
.value("html5Preference", "HTML5_PREF_WANTS")
1097+
.value("signatureTimestamp", sts)
1098+
.value("referer", "https://www.youtube.com/watch?v=" + videoId)
1099+
.value("lactMilliseconds", "-1")
11041100
.end()
1105-
.done())
1106-
.getBytes(UTF_8);
1107-
// @formatter:on
1108-
}
1101+
.end()
1102+
.value(CPN, contentPlaybackNonce)
1103+
.value(VIDEO_ID, videoId)
1104+
.done())
1105+
.getBytes(StandardCharsets.UTF_8);
1106+
// @formatter:on
11091107
}
11101108

11111109
/**
@@ -1381,4 +1379,47 @@ public static String unescapeDocument(@Nonnull final String doc) {
13811379
.replaceAll("\\\\x5b", "[")
13821380
.replaceAll("\\\\x5d", "]");
13831381
}
1382+
1383+
/**
1384+
* Generate a content playback nonce (also called {@code cpn}), sent by YouTube clients in
1385+
* playback requests (and also for some clients, in the player request body).
1386+
*
1387+
* @return a content playback nonce string
1388+
*/
1389+
@Nonnull
1390+
public static String generateContentPlaybackNonce() {
1391+
final SecureRandom random = new SecureRandom();
1392+
final StringBuilder stringBuilder = new StringBuilder();
1393+
1394+
for (int i = 0; i < 16; i++) {
1395+
stringBuilder.append(CONTENT_PLAYBACK_NONCE_ALPHABET.charAt(
1396+
(random.nextInt(128) + 1) & 63));
1397+
}
1398+
1399+
return stringBuilder.toString();
1400+
}
1401+
1402+
/**
1403+
* Try to generate a {@code t} parameter, sent by mobile clients as a query of the player
1404+
* request.
1405+
*
1406+
* <p>
1407+
* Some researches needs to be done to know how this parameter, unique at each request, is
1408+
* generated.
1409+
* </p>
1410+
*
1411+
* @return a 12 characters string to try to reproduce the {@code} parameter
1412+
*/
1413+
@Nonnull
1414+
public static String generateTParameter() {
1415+
final SecureRandom random = new SecureRandom();
1416+
final StringBuilder stringBuilder = new StringBuilder();
1417+
1418+
for (int i = 0; i < 12; i++) {
1419+
stringBuilder.append(CONTENT_PLAYBACK_NONCE_ALPHABET.charAt(
1420+
(random.nextInt(128) + 1) & 63));
1421+
}
1422+
1423+
return stringBuilder.toString();
1424+
}
13841425
}

0 commit comments

Comments
 (0)