Skip to content

Commit

Permalink
feat(YouTube/Spoof client): selectively spoof client for general vide…
Browse files Browse the repository at this point in the history
…o / livestreams / Shorts / fallback (unplayable video)
  • Loading branch information
inotia00 committed May 30, 2024
1 parent 41d50ae commit 93defa4
Show file tree
Hide file tree
Showing 6 changed files with 524 additions and 94 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package app.revanced.integrations.youtube.patches.misc;

import org.jetbrains.annotations.NotNull;

/**
* @noinspection ALL
*/
public final class LiveStreamRenderer {
public final String videoId;
public final String client;
public final boolean playabilityOk;
public final boolean isLive;

public LiveStreamRenderer(String videoId, String client, boolean playabilityOk, boolean isLive) {
this.videoId = videoId;
this.client = client;
this.playabilityOk = playabilityOk;
this.isLive = isLive;
}

@NotNull
@Override
public String toString() {
return "LiveStreamRenderer{" +
"videoId=" + videoId +
", client=" + client +
", playabilityOk=" + playabilityOk +
", isLive=" + isLive +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
package app.revanced.integrations.youtube.patches.misc;

import static app.revanced.integrations.shared.utils.Utils.submitOnBackgroundThread;
import static app.revanced.integrations.youtube.patches.misc.requests.LiveStreamRendererRequester.getLiveStreamRenderer;

import android.net.Uri;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import org.apache.commons.lang3.StringUtils;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import app.revanced.integrations.shared.utils.Logger;
import app.revanced.integrations.youtube.patches.misc.requests.PlayerRoutes.ClientType;
import app.revanced.integrations.youtube.settings.Settings;
import app.revanced.integrations.youtube.shared.PlayerType;

Expand All @@ -16,49 +25,6 @@
*/
public class SpoofClientPatch {
private static final boolean SPOOF_CLIENT_ENABLED = Settings.SPOOF_CLIENT.get();
private static final boolean SPOOF_CLIENT_USE_IOS = Settings.SPOOF_CLIENT_USE_IOS.get();

/**
* The device machine id for the Meta Quest 3, used to get opus codec with the Android VR client.
*
* <p>
* See <a href="https://dumps.tadiphone.dev/dumps/oculus/eureka">this GitLab</a> for more
* information.
* </p>
*/
private static final String ANDROID_VR_DEVICE_MODEL = "Quest 3";

/**
* The hardcoded client version of the Android VR app used for InnerTube requests with this client.
*
* <p>
* It can be extracted by getting the latest release version of the app on
* <a href="https://www.meta.com/en-us/experiences/2002317119880945/">the App
* Store page of the YouTube app</a>, in the {@code Additional details} section.
* </p>
*/
private static final String ANDROID_VR_YOUTUBE_CLIENT_VERSION = "1.56.21";

/**
* The device machine id for the iPhone 15 Pro Max, used to get 60fps with the iOS client.
*
* <p>
* See <a href="https://gist.github.com/adamawolf/3048717">this GitHub Gist</a> for more
* information.
* </p>
*/
private static final String IOS_DEVICE_MODEL = "iPhone16,2";

/**
* The hardcoded client version of the iOS app used for InnerTube requests with this client.
*
* <p>
* It can be extracted by getting the latest release version of the app on
* <a href="https://apps.apple.com/us/app/youtube-watch-listen-stream/id544007664/">the App
* Store page of the YouTube app</a>, in the {@code What’s New} section.
* </p>
*/
private static final String IOS_YOUTUBE_CLIENT_VERSION = "19.20.2";

/**
* Clips or Shorts Parameters.
Expand All @@ -71,14 +37,23 @@ public class SpoofClientPatch {
/**
* iOS client is used for Clips or Shorts.
*/
private static volatile boolean useIOSClient;
private static volatile boolean isShortsOrClips;

/**
* 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);

/**
* Last video id loaded. Used to prevent reloading the same spec multiple times.
*/
@Nullable
private static volatile String lastPlayerResponseVideoId;

@Nullable
private static volatile Future<LiveStreamRenderer> rendererFuture;

/**
* Injection point.
* Blocks /get_watch requests by returning a localhost URI.
Expand Down Expand Up @@ -116,9 +91,17 @@ public static String blockInitPlaybackRequest(String originalUrlString) {
String path = originalUri.getPath();

if (path != null && path.contains("initplayback")) {
Logger.printDebug(() -> "Blocking: " + originalUrlString + " by returning unreachable url");
String replacementUriString = (getSpoofClientType() != ClientType.ANDROID_TESTSUITE)
? UNREACHABLE_HOST_URI_STRING
// TODO: Ideally, a local proxy could be setup and block
// the request the same way as Burp Suite is capable of
// because that way the request is never sent to YouTube unnecessarily.
// Just using localhost unfortunately does not work.
: originalUri.buildUpon().clearQuery().build().toString();

Logger.printDebug(() -> "Blocking: " + originalUrlString + " by returning: " + replacementUriString);

return UNREACHABLE_HOST_URI_STRING;
return replacementUriString;
}
} catch (Exception ex) {
Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
Expand All @@ -129,10 +112,19 @@ public static String blockInitPlaybackRequest(String originalUrlString) {
}

private static ClientType getSpoofClientType() {
if (SPOOF_CLIENT_USE_IOS || useIOSClient) {
return ClientType.IOS;
if (isShortsOrClips) {
return Settings.SPOOF_CLIENT_SHORTS.get();
}
return ClientType.ANDROID_VR;
LiveStreamRenderer renderer = getRenderer(false);
if (renderer != null) {
if (renderer.isLive) {
return Settings.SPOOF_CLIENT_LIVESTREAM.get();
}
if (!renderer.playabilityOk) {
return Settings.SPOOF_CLIENT_FALLBACK.get();
}
}
return Settings.SPOOF_CLIENT_GENERAL.get();
}

/**
Expand Down Expand Up @@ -186,7 +178,13 @@ public static boolean enablePlayerGesture(boolean original) {
* Injection point.
*/
public static String setPlayerResponseVideoId(@NonNull String videoId, @Nullable String parameters, boolean isShortAndOpeningOrPlaying) {
useIOSClient = playerParameterIsClipsOrShorts(parameters);
if (SPOOF_CLIENT_ENABLED) {
isShortsOrClips = playerParameterIsClipsOrShorts(parameters);

if (!isShortsOrClips) {
fetchLiveStreamRenderer(videoId, Settings.SPOOF_CLIENT_GENERAL.get());
}
}

return parameters; // Return the original value since we are observing and not modifying.
}
Expand All @@ -202,18 +200,32 @@ private static boolean playerParameterIsClipsOrShorts(@Nullable String playerPar
return playerParameter != null && StringUtils.startsWithAny(playerParameter, CLIPS_OR_SHORTS_PARAMETERS);
}

private enum ClientType {
ANDROID_VR(28, ANDROID_VR_DEVICE_MODEL, ANDROID_VR_YOUTUBE_CLIENT_VERSION),
IOS(5, IOS_DEVICE_MODEL, IOS_YOUTUBE_CLIENT_VERSION);

final int id;
final String model;
final String version;
private static void fetchLiveStreamRenderer(@NonNull String videoId, @NonNull ClientType clientType) {
if (!videoId.equals(lastPlayerResponseVideoId)) {
rendererFuture = submitOnBackgroundThread(() -> getLiveStreamRenderer(videoId, clientType));
lastPlayerResponseVideoId = videoId;
}
// Block until the renderer fetch completes.
// This is desired because if this returns without finishing the fetch
// then video will start playback but the storyboard is not ready yet.
getRenderer(true);
}

ClientType(int id, String model, String version) {
this.id = id;
this.model = model;
this.version = version;
@Nullable
private static LiveStreamRenderer getRenderer(boolean waitForCompletion) {
Future<LiveStreamRenderer> future = rendererFuture;
if (future != null) {
try {
if (waitForCompletion || future.isDone()) {
return future.get(20000, TimeUnit.MILLISECONDS); // Any arbitrarily large timeout.
} // else, return null.
} catch (TimeoutException ex) {
Logger.printDebug(() -> "Could not get renderer (get timed out)");
} catch (ExecutionException | InterruptedException ex) {
// Should never happen.
Logger.printException(() -> "Could not get renderer", ex);
}
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package app.revanced.integrations.youtube.patches.misc.requests;

import static app.revanced.integrations.youtube.patches.misc.requests.PlayerRoutes.GET_LIVE_STREAM_RENDERER;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

import app.revanced.integrations.shared.requests.Requester;
import app.revanced.integrations.shared.utils.Logger;
import app.revanced.integrations.shared.utils.Utils;
import app.revanced.integrations.youtube.patches.misc.LiveStreamRenderer;
import app.revanced.integrations.youtube.patches.misc.requests.PlayerRoutes.ClientType;

public class LiveStreamRendererRequester {

private LiveStreamRendererRequester() {
}

private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex) {
Logger.printInfo(() -> toastMessage, ex);
}

@Nullable
private static JSONObject fetchPlayerResponse(@NonNull String requestBody,
@NonNull String userAgent) {
final long startTime = System.currentTimeMillis();
try {
Utils.verifyOffMainThread();
Objects.requireNonNull(requestBody);

final byte[] innerTubeBody = requestBody.getBytes(StandardCharsets.UTF_8);

HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_LIVE_STREAM_RENDERER, userAgent);
connection.getOutputStream().write(innerTubeBody, 0, innerTubeBody.length);

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

// Always show a toast for this, as a non 200 response means something is broken.
handleConnectionError("Spoof storyboard not available: " + responseCode, null);
connection.disconnect();
} catch (SocketTimeoutException ex) {
handleConnectionError("Spoof storyboard temporarily not available (API timed out)", ex);
} catch (IOException ex) {
handleConnectionError("Spoof storyboard temporarily not available: " + ex.getMessage(), ex);
} catch (Exception ex) {
Logger.printException(() -> "Spoof storyboard fetch failed", ex); // Should never happen.
} finally {
Logger.printDebug(() -> "Request took: " + (System.currentTimeMillis() - startTime) + "ms");
}

return null;
}

private static boolean isPlayabilityStatusOk(@NonNull JSONObject playerResponse) {
try {
return playerResponse.getJSONObject("playabilityStatus").getString("status").equals("OK");
} catch (JSONException e) {
Logger.printDebug(() -> "Failed to get playabilityStatus for response: " + playerResponse);
}

return false;
}

private static boolean isLiveStream(@NonNull JSONObject playerResponse) {
try {
return playerResponse.getJSONObject("videoDetails").getBoolean("isLive");
} catch (JSONException e) {
Logger.printDebug(() -> "Failed to get videoDetails for response: " + playerResponse);
}

return false;
}

/**
* Fetches the liveStreamRenderer from the innerTubeBody.
*
* @return LiveStreamRenderer or null if playabilityStatus is not OK.
*/
@Nullable
private static LiveStreamRenderer getLiveStreamRendererUsingBody(@NonNull String videoId,
@NonNull ClientType clientType) {
final JSONObject playerResponse = fetchPlayerResponse(
String.format(clientType.innerTubeBody, videoId),
clientType.userAgent
);
if (playerResponse != null)
return getLiveStreamRendererUsingResponse(videoId, playerResponse, clientType);

return null;
}

@Nullable
private static LiveStreamRenderer getLiveStreamRendererUsingResponse(@NonNull String videoId,
@NonNull JSONObject playerResponse,
@NonNull ClientType clientType) {
try {
Logger.printDebug(() -> "Parsing liveStreamRenderer from response: " + playerResponse);

final String clientName = clientType.name();
final boolean isPlayabilityOk = isPlayabilityStatusOk(playerResponse);
final boolean isLiveStream = isLiveStream(playerResponse);

LiveStreamRenderer renderer = new LiveStreamRenderer(
videoId,
clientName,
isPlayabilityOk,
isLiveStream
);
Logger.printDebug(() -> "Fetched: " + renderer);

return renderer;
} catch (Exception e) {
Logger.printException(() -> "Failed to get liveStreamRenderer", e);
}

return null;
}

@Nullable
public static LiveStreamRenderer getLiveStreamRenderer(@NonNull String videoId, @NonNull ClientType clientType) {
Objects.requireNonNull(videoId);

LiveStreamRenderer renderer = getLiveStreamRendererUsingBody(
videoId,
clientType
);
if (renderer == null) {
String finalClientName1 = clientType.name();
Logger.printDebug(() -> videoId + " not available using " + finalClientName1 + " client");

clientType = ClientType.TVHTML5_SIMPLY_EMBEDDED_PLAYER;
renderer = getLiveStreamRendererUsingBody(
videoId,
clientType
);
if (renderer == null) {
String finalClientName2 = clientType.name();
Logger.printDebug(() -> videoId + " not available using " + finalClientName2 + " client");
}
}

return renderer;
}
}
Loading

0 comments on commit 93defa4

Please sign in to comment.