Skip to content

Commit 163af60

Browse files
committed
[YouTube] Use poTokens where needed and rework JSON requests
1 parent 6d58fe7 commit 163af60

5 files changed

Lines changed: 708 additions & 382 deletions

File tree

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

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
* These tokens may have a role in triggering the sign in requirement.
2222
* </p>
2323
*
24-
* @implNote This interface is expected to be thread-safe,
25-
* as it may be accessed by multiple threads.
24+
* <p>
25+
* <b>Implementations of this interface are expected to be thread-safe, as they may be accessed by
26+
* multiple threads.</b>
27+
* </p>
2628
*/
2729
public interface PoTokenProvider {
2830

@@ -35,11 +37,77 @@ public interface PoTokenProvider {
3537
* must be added to adaptive/DASH streaming URLs with the {@code pot} parameter.
3638
* </p>
3739
*
40+
* <p>
41+
* Note that YouTube desktop website generates two poTokens:
42+
* - one for the player requests poTokens, using the videoId as the minter value;
43+
* - one for the streaming URLs, using a visitor data for logged-out users.
44+
* </p>
45+
*
3846
* @return a {@link PoTokenResult} specific to the WEB InnerTube client
3947
*/
4048
@Nullable
41-
PoTokenResult getWebClientPoToken();
49+
PoTokenResult getWebClientPoToken(String videoId);
4250

51+
/**
52+
* Get a {@link PoTokenResult} specific to the web embeds, a.k.a. the WEB_EMBEDDED_PLAYER
53+
* InnerTube client.
54+
*
55+
* <p>
56+
* To be generated and valid, poTokens from this client must be generated using Google's
57+
* BotGuard machine, which requires a JavaScript engine with a good DOM implementation. They
58+
* should be added to adaptive/DASH streaming URLs with the {@code pot} parameter and do not
59+
* seem to be mandatory for now.
60+
* </p>
61+
*
62+
* <p>
63+
* As of writing, like the YouTube desktop website previously did, it generates only one
64+
* poToken, sent in player requests and streaming URLs, using a visitor data for logged-out
65+
* users.
66+
* </p>
67+
*
68+
* @return a {@link PoTokenResult} specific to the WEB_EMBEDDED_PLAYER InnerTube client
69+
*/
70+
@Nullable
71+
PoTokenResult getWebEmbedClientPoToken(String videoId);
72+
73+
/**
74+
* Get a {@link PoTokenResult} specific to the Android app, a.k.a. the ANDROID InnerTube client.
75+
*
76+
* <p>
77+
* Implementation details are not known, the app uses DroidGuard, a native virtual machine
78+
* ran by Google Play Services for which its code is updated pretty frequently.
79+
* </p>
80+
*
81+
* <p>
82+
* As of writing, DroidGuard seem to check for the Android app signature and package ID, as
83+
* unrooted YouTube patched with reVanced doesn't work without spoofing another InnerTube
84+
* client while the rooted version works without any client spoofing.
85+
* </p>
86+
*
87+
* <p>
88+
* There should be only poToken needed, for the player requests.
89+
* </p>
90+
*
91+
* @return a {@link PoTokenResult} specific to the ANDROID InnerTube client
92+
*/
93+
@Nullable
94+
PoTokenResult getAndroidClientPoToken(String videoId);
95+
96+
/**
97+
* Get a {@link PoTokenResult} specific to the Android app, a.k.a. the ANDROID InnerTube client.
98+
*
99+
* <p>
100+
* Implementation details are not really known, the app seem to use something called
101+
* iosGuard which should be something similar to Android's DroidGuard. It may rely on Apple's
102+
* attestation APIs.
103+
* </p>
104+
*
105+
* <p>
106+
* There should be only poToken needed, for the player requests.
107+
* </p>
108+
*
109+
* @return a {@link PoTokenResult} specific to the IOS InnerTube client
110+
*/
43111
@Nullable
44-
PoTokenResult getAndroidClientPoToken();
112+
PoTokenResult getIosClientPoToken(String videoId);
45113
}
Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,40 @@
11
package org.schabi.newpipe.extractor.services.youtube;
22

33
import javax.annotation.Nonnull;
4+
import javax.annotation.Nullable;
45
import java.util.Objects;
56

67
public final class PoTokenResult {
78

89
/**
9-
* The visitor data associated with a poToken.
10+
* The visitor data associated with a {@code poToken}.
1011
*/
12+
@Nonnull
1113
public final String visitorData;
1214

1315
/**
14-
* The poToken, a Protobuf object encoded as a base 64 string.
16+
* The {@code poToken} of a player request, a Protobuf object encoded as a base 64 string.
1517
*/
16-
public final String poToken;
18+
@Nonnull
19+
public final String playerRequestPoToken;
1720

18-
public PoTokenResult(@Nonnull final String visitorData, @Nonnull final String poToken) {
21+
/**
22+
* The {@code poToken} to be appended to streaming URLs, a Protobuf object encoded as a base
23+
* 64 string.
24+
*
25+
* <p>
26+
* It may be required on some clients such as HTML5 ones and may also differ from the player
27+
* request {@code poToken}.
28+
* </p>
29+
*/
30+
@Nullable
31+
public final String streamingDataPoToken;
32+
33+
public PoTokenResult(@Nonnull final String visitorData,
34+
@Nonnull final String playerRequestPoToken,
35+
@Nullable final String streamingDataPoToken) {
1936
this.visitorData = Objects.requireNonNull(visitorData);
20-
this.poToken = Objects.requireNonNull(poToken);
37+
this.playerRequestPoToken = Objects.requireNonNull(playerRequestPoToken);
38+
this.streamingDataPoToken = streamingDataPoToken;
2139
}
2240
}

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

Lines changed: 3 additions & 188 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@
2525
import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.DESKTOP_CLIENT_PLATFORM;
2626
import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_CLIENT_VERSION;
2727
import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_DEVICE_MODEL;
28-
import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_OS_VERSION;
2928
import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.IOS_USER_AGENT_VERSION;
30-
import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.TVHTML5_CLIENT_VERSION;
3129
import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_CLIENT_ID;
3230
import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_CLIENT_NAME;
3331
import static org.schabi.newpipe.extractor.services.youtube.ClientsConstants.WEB_HARDCODED_CLIENT_VERSION;
@@ -1062,43 +1060,6 @@ public static JsonObject getJsonPostResponse(final String endpoint,
10621060
+ DISABLE_PRETTY_PRINT_PARAMETER, headers, body, localization)));
10631061
}
10641062

1065-
public static JsonObject getJsonAndroidPostResponse(
1066-
final String endpoint,
1067-
final byte[] body,
1068-
@Nonnull final Localization localization,
1069-
@Nullable final String endPartOfUrlRequest) throws IOException, ExtractionException {
1070-
return getMobilePostResponse(endpoint, body, localization,
1071-
getAndroidUserAgent(localization), endPartOfUrlRequest);
1072-
}
1073-
1074-
public static JsonObject getJsonIosPostResponse(
1075-
final String endpoint,
1076-
final byte[] body,
1077-
@Nonnull final Localization localization,
1078-
@Nullable final String endPartOfUrlRequest) throws IOException, ExtractionException {
1079-
return getMobilePostResponse(endpoint, body, localization, getIosUserAgent(localization),
1080-
endPartOfUrlRequest);
1081-
}
1082-
1083-
private static JsonObject getMobilePostResponse(
1084-
final String endpoint,
1085-
final byte[] body,
1086-
@Nonnull final Localization localization,
1087-
@Nonnull final String userAgent,
1088-
@Nullable final String endPartOfUrlRequest) throws IOException, ExtractionException {
1089-
final var headers = Map.of("User-Agent", List.of(userAgent),
1090-
"X-Goog-Api-Format-Version", List.of("2"));
1091-
1092-
final String baseEndpointUrl = YOUTUBEI_V1_GAPIS_URL + endpoint + "?"
1093-
+ DISABLE_PRETTY_PRINT_PARAMETER;
1094-
1095-
return JsonUtils.toJsonObject(getValidJsonResponseBody(
1096-
getDownloader().postWithContentTypeJson(isNullOrEmpty(endPartOfUrlRequest)
1097-
? baseEndpointUrl
1098-
: baseEndpointUrl + endPartOfUrlRequest,
1099-
headers, body, localization)));
1100-
}
1101-
11021063
@Nonnull
11031064
public static JsonBuilder<JsonObject> prepareDesktopJsonBuilder(
11041065
@Nonnull final Localization localization,
@@ -1145,152 +1106,6 @@ public static JsonBuilder<JsonObject> prepareDesktopJsonBuilder(
11451106
// @formatter:on
11461107
}
11471108

1148-
@Nonnull
1149-
public static JsonBuilder<JsonObject> prepareAndroidMobileJsonBuilder(
1150-
@Nonnull final Localization localization,
1151-
@Nonnull final ContentCountry contentCountry,
1152-
@Nullable final String visitorData) {
1153-
// @formatter:off
1154-
final JsonBuilder<JsonObject> builder = JsonObject.builder()
1155-
.object("context")
1156-
.object("client")
1157-
.value("clientName", "ANDROID")
1158-
.value("clientVersion", ANDROID_CLIENT_VERSION)
1159-
.value("platform", "MOBILE")
1160-
.value("osName", "Android")
1161-
.value("osVersion", "14")
1162-
/*
1163-
A valid Android SDK version is required to be sure to get a valid player
1164-
response
1165-
If this parameter is not provided, the player response is replaced by an
1166-
error saying the message "The following content is not available on this
1167-
app. Watch this content on the latest version on YouTube" (it was
1168-
previously a 5-minute video with this message)
1169-
See https://github.com/TeamNewPipe/NewPipe/issues/8713
1170-
The Android SDK version corresponding to the Android version used in
1171-
requests is sent
1172-
*/
1173-
.value("androidSdkVersion", 34)
1174-
.value("hl", localization.getLocalizationCode())
1175-
.value("gl", contentCountry.getCountryCode())
1176-
.value("utcOffsetMinutes", 0);
1177-
1178-
if (visitorData != null) {
1179-
builder.value("visitorData", visitorData);
1180-
}
1181-
1182-
builder.end()
1183-
.object("request")
1184-
.array("internalExperimentFlags")
1185-
.end()
1186-
.value("useSsl", true)
1187-
.end()
1188-
.object("user")
1189-
// TODO: provide a way to enable restricted mode with:
1190-
// .value("enableSafetyMode", boolean)
1191-
.value("lockedSafetyMode", false)
1192-
.end()
1193-
.end();
1194-
// @formatter:on
1195-
return builder;
1196-
}
1197-
1198-
@Nonnull
1199-
public static JsonBuilder<JsonObject> prepareIosMobileJsonBuilder(
1200-
@Nonnull final Localization localization,
1201-
@Nonnull final ContentCountry contentCountry) {
1202-
// @formatter:off
1203-
return JsonObject.builder()
1204-
.object("context")
1205-
.object("client")
1206-
.value("clientName", "IOS")
1207-
.value("clientVersion", IOS_CLIENT_VERSION)
1208-
.value("deviceMake", "Apple")
1209-
// Device model is required to get 60fps streams
1210-
.value("deviceModel", IOS_DEVICE_MODEL)
1211-
.value("platform", "MOBILE")
1212-
.value("osName", "iOS")
1213-
.value("osVersion", IOS_OS_VERSION)
1214-
.value("visitorData", randomVisitorData(contentCountry))
1215-
.value("hl", localization.getLocalizationCode())
1216-
.value("gl", contentCountry.getCountryCode())
1217-
.value("utcOffsetMinutes", 0)
1218-
.end()
1219-
.object("request")
1220-
.array("internalExperimentFlags")
1221-
.end()
1222-
.value("useSsl", true)
1223-
.end()
1224-
.object("user")
1225-
// TODO: provide a way to enable restricted mode with:
1226-
// .value("enableSafetyMode", boolean)
1227-
.value("lockedSafetyMode", false)
1228-
.end()
1229-
.end();
1230-
// @formatter:on
1231-
}
1232-
1233-
@Nonnull
1234-
public static JsonBuilder<JsonObject> prepareTvHtml5EmbedJsonBuilder(
1235-
@Nonnull final Localization localization,
1236-
@Nonnull final ContentCountry contentCountry,
1237-
@Nonnull final String videoId) {
1238-
// @formatter:off
1239-
return JsonObject.builder()
1240-
.object("context")
1241-
.object("client")
1242-
.value("clientName", "TVHTML5_SIMPLY_EMBEDDED_PLAYER")
1243-
.value("clientVersion", TVHTML5_CLIENT_VERSION)
1244-
.value("clientScreen", "EMBED")
1245-
.value("platform", "TV")
1246-
.value("hl", localization.getLocalizationCode())
1247-
.value("gl", contentCountry.getCountryCode())
1248-
.value("utcOffsetMinutes", 0)
1249-
.end()
1250-
.object("thirdParty")
1251-
.value("embedUrl", "https://www.youtube.com/watch?v=" + videoId)
1252-
.end()
1253-
.object("request")
1254-
.array("internalExperimentFlags")
1255-
.end()
1256-
.value("useSsl", true)
1257-
.end()
1258-
.object("user")
1259-
// TODO: provide a way to enable restricted mode with:
1260-
// .value("enableSafetyMode", boolean)
1261-
.value("lockedSafetyMode", false)
1262-
.end()
1263-
.end();
1264-
// @formatter:on
1265-
}
1266-
1267-
@Nonnull
1268-
public static byte[] createTvHtml5EmbedPlayerBody(
1269-
@Nonnull final Localization localization,
1270-
@Nonnull final ContentCountry contentCountry,
1271-
@Nonnull final String videoId,
1272-
@Nonnull final Integer sts,
1273-
@Nonnull final String contentPlaybackNonce) {
1274-
// @formatter:off
1275-
return JsonWriter.string(
1276-
prepareTvHtml5EmbedJsonBuilder(localization, contentCountry, videoId)
1277-
.object("playbackContext")
1278-
.object("contentPlaybackContext")
1279-
// Signature timestamp from the JavaScript base player is needed to get
1280-
// working obfuscated URLs
1281-
.value("signatureTimestamp", sts)
1282-
.value("referer", "https://www.youtube.com/watch?v=" + videoId)
1283-
.end()
1284-
.end()
1285-
.value(CPN, contentPlaybackNonce)
1286-
.value(VIDEO_ID, videoId)
1287-
.value(CONTENT_CHECK_OK, true)
1288-
.value(RACY_CHECK_OK, true)
1289-
.done())
1290-
.getBytes(StandardCharsets.UTF_8);
1291-
// @formatter:on
1292-
}
1293-
12941109
/**
12951110
* Get the user-agent string used as the user-agent for InnerTube requests with the Android
12961111
* client.
@@ -1371,7 +1186,7 @@ public static Map<String, List<String>> getClientInfoHeaders()
13711186
*
13721187
* @param url The URL to be set as the origin and referrer.
13731188
*/
1374-
private static Map<String, List<String>> getOriginReferrerHeaders(@Nonnull final String url) {
1189+
static Map<String, List<String>> getOriginReferrerHeaders(@Nonnull final String url) {
13751190
final var urlList = List.of(url);
13761191
return Map.of("Origin", urlList, "Referer", urlList);
13771192
}
@@ -1383,8 +1198,8 @@ private static Map<String, List<String>> getOriginReferrerHeaders(@Nonnull final
13831198
* @param name The X-YouTube-Client-Name value.
13841199
* @param version X-YouTube-Client-Version value.
13851200
*/
1386-
private static Map<String, List<String>> getClientHeaders(@Nonnull final String name,
1387-
@Nonnull final String version) {
1201+
static Map<String, List<String>> getClientHeaders(@Nonnull final String name,
1202+
@Nonnull final String version) {
13881203
return Map.of("X-YouTube-Client-Name", List.of(name),
13891204
"X-YouTube-Client-Version", List.of(version));
13901205
}

0 commit comments

Comments
 (0)