Skip to content

Commit

Permalink
fix(YouTube - Spoof Client): Fix playback by replace streaming data
Browse files Browse the repository at this point in the history
  • Loading branch information
zainarbani committed Aug 24, 2024
1 parent 9e11ba1 commit d498d79
Show file tree
Hide file tree
Showing 4 changed files with 290 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -1,26 +1,48 @@
package app.revanced.integrations.youtube.patches.spoof;

import androidx.annotation.Nullable;
import android.annotation.SuppressLint;
import android.media.MediaCodecInfo;
import android.media.MediaCodecList;
import android.net.Uri;
import android.os.Build;
import app.revanced.integrations.shared.Logger;
import app.revanced.integrations.youtube.patches.BackgroundPlaybackPatch;
import app.revanced.integrations.youtube.patches.spoof.requests.StreamingDataRequester;
import app.revanced.integrations.youtube.settings.Settings;
import java.nio.ByteBuffer;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import org.chromium.net.ExperimentalUrlRequest;

@SuppressWarnings("unused")
public class SpoofClientPatch {
private static final boolean SPOOF_CLIENT_ENABLED = Settings.SPOOF_CLIENT.get();
private static final ClientType SPOOF_CLIENT_TYPE = Settings.SPOOF_CLIENT_USE_IOS.get() ? ClientType.IOS : ClientType.ANDROID_VR;
private static final boolean SPOOFING_TO_IOS = SPOOF_CLIENT_ENABLED && SPOOF_CLIENT_TYPE == ClientType.IOS;
private static final boolean SPOOF_STREAM_ENABLED = Settings.SPOOF_STREAM.get();

/**
* Any unreachable ip address. Used to intentionally fail requests.
*/
private static final String UNREACHABLE_HOST_URI_STRING = "https://127.0.0.0";
private static final Uri UNREACHABLE_HOST_URI = Uri.parse(UNREACHABLE_HOST_URI_STRING);

/**
* Streaming data store.
*/
@Nullable
private static CompletableFuture<ByteBuffer> streamingDataFuture;
private static final ConcurrentHashMap<String, ByteBuffer> streamingDataCache = new ConcurrentHashMap<>();

/**
* Last video id prefetched. Field is to prevent prefetching the same video id multiple times in a row.
*/
@Nullable
private static volatile String lastPrefetchedVideoId;

/**
* Injection point.
* Blocks /get_watch requests by returning an unreachable URI.
Expand All @@ -29,7 +51,7 @@ public class SpoofClientPatch {
* @return An unreachable URI if the request is a /get_watch request, otherwise the original URI.
*/
public static Uri blockGetWatchRequest(Uri playerRequestUri) {
if (SPOOF_CLIENT_ENABLED) {
if (SPOOF_CLIENT_ENABLED || SPOOF_STREAM_ENABLED) {
try {
String path = playerRequestUri.getPath();

Expand All @@ -52,7 +74,7 @@ public static Uri blockGetWatchRequest(Uri playerRequestUri) {
* Blocks /initplayback requests.
*/
public static String blockInitPlaybackRequest(String originalUrlString) {
if (SPOOF_CLIENT_ENABLED) {
if (SPOOF_CLIENT_ENABLED || SPOOF_STREAM_ENABLED) {
try {
var originalUri = Uri.parse(originalUrlString);
String path = originalUri.getPath();
Expand Down Expand Up @@ -113,6 +135,13 @@ public static boolean isClientSpoofingEnabled() {
return SPOOF_CLIENT_ENABLED;
}

/**
* Injection point.
*/
public static boolean isSpoofStreamEnabled() {
return SPOOF_STREAM_ENABLED;
}

/**
* Injection point.
* When spoofing the client to iOS, the playback speed menu is missing from the player response.
Expand All @@ -135,17 +164,72 @@ public static boolean overrideBackgroundAudioPlayback() {
* Injection point.
* Fix video qualities missing, if spoofing to iOS by using the correct iOS user-agent.
*/
public static ExperimentalUrlRequest overrideUserAgent(ExperimentalUrlRequest.Builder builder, String url) {
if (SPOOFING_TO_IOS) {
String path = Uri.parse(url).getPath();
if (path != null && path.contains("player")) {
return builder.addHeader("User-Agent", ClientType.IOS.userAgent).build();
public static ExperimentalUrlRequest overrideUserAgent(ExperimentalUrlRequest.Builder builder, String url, Map headers) {
if (SPOOFING_TO_IOS || SPOOF_STREAM_ENABLED) {
Uri uri = Uri.parse(url);
String path = uri.getPath();
if (path != null && path.contains("player") && !path.contains("heartbeat")) {
if (SPOOFING_TO_IOS) {
return builder.addHeader("User-Agent", ClientType.IOS.userAgent).build();
}
if (SPOOF_STREAM_ENABLED) {
fetchStreamingData(uri.getQueryParameter("id"), headers);
return builder.build();
}
}
}

return builder.build();
}

/**
* Injection point.
* Fix playback by replace the streaming data.
*/
@SuppressLint("NewApi")
public static ByteBuffer getStreamingData(String videoId) {
if (!SPOOF_STREAM_ENABLED) return null;

if (streamingDataCache.containsKey(videoId)) {
return streamingDataCache.get(videoId);
}

if (streamingDataFuture != null) {
try {
ByteBuffer byteBuffer = streamingDataFuture.get();
if (byteBuffer != null) {
streamingDataCache.put(videoId, byteBuffer);
return byteBuffer;
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
Logger.printException(() -> "getStreamingData interrupted.", ex);
} catch (ExecutionException ex) {
Logger.printException(() -> "getStreamingData failure.", ex);
}
}

return null;
}

/**
* Injection point.
*/
public static void fetchStreamingData(String videoId, Map headers) {
if (SPOOF_STREAM_ENABLED) {
if (videoId.equals(lastPrefetchedVideoId)) {
return;
}

if (!streamingDataCache.containsKey(videoId)) {
String authHeader = (String) headers.get("Authorization");
CompletableFuture<ByteBuffer> future = StreamingDataRequester.fetch(videoId, authHeader);
streamingDataFuture = future;
}
lastPrefetchedVideoId = videoId;
}
}

private enum ClientType {
// https://dumps.tadiphone.dev/dumps/oculus/eureka
ANDROID_VR(28,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
import java.net.HttpURLConnection;

final class PlayerRoutes {
private static final String YT_API_URL = "https://www.youtube.com/youtubei/v1/";
private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/";

static final Route.CompiledRoute GET_STORYBOARD_SPEC_RENDERER = new Route(
Route.Method.POST,
"player" +
Expand All @@ -20,7 +21,17 @@ final class PlayerRoutes {
"playabilityStatus.status"
).compile();

static final Route.CompiledRoute GET_STREAMING_DATA = new Route(
Route.Method.POST,
"player" +
"?fields=streamingData" +
"&alt=proto"
).compile();

static final String ANDROID_INNER_TUBE_BODY;
static final String VR_INNER_TUBE_BODY;
static final String UNPLUGGED_INNER_TUBE_BODY;
static final String TESTSUITE_INNER_TUBE_BODY;
static final String TV_EMBED_INNER_TUBE_BODY;

/**
Expand Down Expand Up @@ -49,6 +60,78 @@ final class PlayerRoutes {

ANDROID_INNER_TUBE_BODY = innerTubeBody.toString();

JSONObject vrInnerTubeBody = new JSONObject();

try {
JSONObject context = new JSONObject();

JSONObject client = new JSONObject();
client.put("clientName", "ANDROID_VR");
client.put("clientVersion", "1.58.14");
client.put("deviceModel", "Quest 3");
client.put("osVersion", "12");
client.put("androidSdkVersion", 34);

context.put("client", client);

vrInnerTubeBody.put("contentCheckOk", true);
vrInnerTubeBody.put("racyCheckOk", true);
vrInnerTubeBody.put("context", context);
vrInnerTubeBody.put("videoId", "%s");
} catch (JSONException e) {
Logger.printException(() -> "Failed to create vrInnerTubeBody", e);
}

VR_INNER_TUBE_BODY = vrInnerTubeBody.toString();

JSONObject unpluggedInnerTubeBody = new JSONObject();

try {
JSONObject context = new JSONObject();

JSONObject client = new JSONObject();
client.put("clientName", "ANDROID_UNPLUGGED");
client.put("clientVersion", "8.33.0");
client.put("deviceModel", "ADT-3");
client.put("osVersion", "14");
client.put("androidSdkVersion", 34);

context.put("client", client);

unpluggedInnerTubeBody.put("contentCheckOk", true);
unpluggedInnerTubeBody.put("racyCheckOk", true);
unpluggedInnerTubeBody.put("context", context);
unpluggedInnerTubeBody.put("videoId", "%s");
} catch (JSONException e) {
Logger.printException(() -> "Failed to create unpluggedInnerTubeBody", e);
}

UNPLUGGED_INNER_TUBE_BODY = unpluggedInnerTubeBody.toString();

JSONObject suiteInnerTubeBody = new JSONObject();

try {
JSONObject context = new JSONObject();

JSONObject client = new JSONObject();
client.put("clientName", "ANDROID_TESTSUITE");
client.put("clientVersion", "1.9");
client.put("deviceModel", "Pixel 8 Pro");
client.put("osVersion", "14");
client.put("androidSdkVersion", 34);

context.put("client", client);

suiteInnerTubeBody.put("contentCheckOk", true);
suiteInnerTubeBody.put("racyCheckOk", true);
suiteInnerTubeBody.put("context", context);
suiteInnerTubeBody.put("videoId", "%s");
} catch (JSONException e) {
Logger.printException(() -> "Failed to create suiteInnerTubeBody", e);
}

TESTSUITE_INNER_TUBE_BODY = suiteInnerTubeBody.toString();

JSONObject tvEmbedInnerTubeBody = new JSONObject();

try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package app.revanced.integrations.youtube.patches.spoof.requests;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.annotation.SuppressLint;

import app.revanced.integrations.shared.settings.BaseSettings;
import app.revanced.integrations.shared.Logger;
import app.revanced.integrations.shared.Utils;

import java.io.ByteArrayOutputStream;
import java.io.BufferedInputStream;
import java.io.BufferedInputStream;
import java.io.InputStream;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;

import static app.revanced.integrations.youtube.patches.spoof.requests.PlayerRoutes.*;

public class StreamingDataRequester {
private static final boolean showToastOnException = false;

private StreamingDataRequester() {
}

private static void handleConnectionError(String toastMessage, @Nullable Exception ex, boolean showToast) {
if (showToast) Utils.showToastShort(toastMessage);
Logger.printInfo(() -> toastMessage, ex);
}

@Nullable
private static HttpURLConnection send(String requestBody, String authHeader) {
final long startTime = System.currentTimeMillis();
try {
HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA);

// Required for age restricted videos.
if (authHeader != null) {
connection.setRequestProperty("authorization", authHeader);
}

final byte[] innerTubeBody = requestBody.getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(innerTubeBody.length);
connection.getOutputStream().write(innerTubeBody);

final int responseCode = connection.getResponseCode();
if (responseCode == 200) return connection;

handleConnectionError("Not available: " + responseCode, null,
showToastOnException || BaseSettings.DEBUG_TOAST_ON_ERROR.get());
} catch (SocketTimeoutException ex) {
handleConnectionError("Connection timeout.", ex, showToastOnException);
} catch (IOException ex) {
handleConnectionError("Network error.", ex, showToastOnException);
} catch (Exception ex) {
Logger.printException(() -> "Request failed.", ex);
} finally {
Logger.printDebug(() -> "Took: " + (System.currentTimeMillis() - startTime) + "ms");
}

return null;
}

@SuppressLint("NewApi")
public static CompletableFuture<ByteBuffer> fetch(@NonNull String videoId, String authHeader) {
Objects.requireNonNull(videoId);

return CompletableFuture.supplyAsync(() -> {
ByteBuffer finalBuffer = null;

// Retry with different client if empty streaming data is received.
List<String> innerTubeBodies = List.of(
VR_INNER_TUBE_BODY,
UNPLUGGED_INNER_TUBE_BODY,
TESTSUITE_INNER_TUBE_BODY
);

for (String body : innerTubeBodies) {
HttpURLConnection connection = send(String.format(body, videoId), authHeader);
if (connection != null) {
try {
// gzip encoding doesn't response with content length (-1),
// but empty response body does.
if (connection.getContentLength() != 0) {
InputStream inputStream = new BufferedInputStream(connection.getInputStream());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
baos.write(buffer, 0, bytesRead);
}
finalBuffer = ByteBuffer.wrap(baos.toByteArray());
break;
}
} catch (IOException ex) {
Logger.printException(() -> "Failed while processing response data.", ex);
}
}
}

if (finalBuffer == null) {
handleConnectionError("No streaming data available.", null, showToastOnException);
}

return finalBuffer;
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ public class Settings extends BaseSettings {
public static final BooleanSetting ANNOUNCEMENTS = new BooleanSetting("revanced_announcements", TRUE);
public static final BooleanSetting SPOOF_CLIENT = new BooleanSetting("revanced_spoof_client", TRUE, true, "revanced_spoof_client_user_dialog_message");
public static final BooleanSetting SPOOF_CLIENT_USE_IOS = new BooleanSetting("revanced_spoof_client_use_ios", TRUE, true, parent(SPOOF_CLIENT));
public static final BooleanSetting SPOOF_STREAM = new BooleanSetting("revanced_spoof_stream", FALSE, true);
@Deprecated
public static final StringSetting DEPRECATED_ANNOUNCEMENT_LAST_HASH = new StringSetting("revanced_announcement_last_hash", "");
public static final IntegerSetting ANNOUNCEMENT_LAST_ID = new IntegerSetting("revanced_announcement_last_id", -1);
Expand Down

0 comments on commit d498d79

Please sign in to comment.