Skip to content

Commit

Permalink
fix(YouTube - Client spoof): Spoof client to fix playback (#637)
Browse files Browse the repository at this point in the history
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com>
  • Loading branch information
oSumAtrIX and LisoUseInAIKyrios committed May 21, 2024
1 parent ea184d0 commit 4c1f82a
Show file tree
Hide file tree
Showing 8 changed files with 304 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public static boolean playerParametersAreShort(@NonNull String parameters) {
/**
* Injection point.
*/
public static String newPlayerResponseSignature(@NonNull String signature, boolean isShortAndOpeningOrPlaying) {
public static String newPlayerResponseSignature(@NonNull String signature, String videoId, boolean isShortAndOpeningOrPlaying) {
final boolean isShort = playerParametersAreShort(signature);
playerResponseVideoIdIsShort = isShort;
if (!isShort || isShortAndOpeningOrPlaying) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
package app.revanced.integrations.youtube.patches.spoof;

import static app.revanced.integrations.youtube.patches.spoof.requests.StoryboardRendererRequester.getStoryboardRenderer;

import android.net.Uri;

import androidx.annotation.Nullable;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
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.Logger;
import app.revanced.integrations.shared.Utils;
import app.revanced.integrations.youtube.patches.VideoInformation;
import app.revanced.integrations.youtube.settings.Settings;

@SuppressWarnings("unused")
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();
private static final boolean SPOOF_CLIENT_STORYBOARD = SPOOF_CLIENT_ENABLED && !SPOOF_CLIENT_USE_IOS;

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

@Nullable
private static volatile Future<StoryboardRenderer> lastStoryboardFetched;

private static final Map<String, Future<StoryboardRenderer>> storyboardCache =
Collections.synchronizedMap(new LinkedHashMap<>(100) {
private static final int CACHE_LIMIT = 100;

@Override
protected boolean removeEldestEntry(Entry eldest) {
return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
}
});

/**
* Injection point.
* Blocks /get_watch requests by returning a localhost URI.
*
* @param playerRequestUri The URI of the player request.
* @return Localhost URI if the request is a /get_watch request, otherwise the original URI.
*/
public static Uri blockGetWatchRequest(Uri playerRequestUri) {
if (SPOOF_CLIENT_ENABLED) {
try {
String path = playerRequestUri.getPath();

if (path != null && path.contains("get_watch")) {
Logger.printDebug(() -> "Blocking: " + playerRequestUri + " by returning: " + UNREACHABLE_HOST_URI_STRING);

return UNREACHABLE_HOST_URI;
}
} catch (Exception ex) {
Logger.printException(() -> "blockGetWatchRequest failure", ex);
}
}

return playerRequestUri;
}

/**
* Injection point.
* Blocks /initplayback requests.
* For iOS, an unreachable host URL can be used, but for Android Testsuite, this is not possible.
*/
public static String blockInitPlaybackRequest(String originalUrlString) {
if (SPOOF_CLIENT_ENABLED) {
try {
var originalUri = Uri.parse(originalUrlString);
String path = originalUri.getPath();

if (path != null && path.contains("initplayback")) {
String replacementUriString = (getSpoofClientType() == ClientType.IOS)
? 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 replacementUriString;
}
} catch (Exception ex) {
Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
}
}

return originalUrlString;
}

private static ClientType getSpoofClientType() {
if (SPOOF_CLIENT_USE_IOS) {
return ClientType.IOS;
}

StoryboardRenderer renderer = getRenderer(false);
if (renderer == null) {
// Video is private or otherwise not available.
// Test client still works for video playback, but seekbar thumbnails are not available.
// Use iOS client instead.
Logger.printDebug(() -> "Using iOS client for paid or otherwise restricted video");
return ClientType.IOS;
}

if (renderer.isLiveStream) {
// Test client does not support live streams.
// Use the storyboard renderer information to fallback to iOS if a live stream is opened.
Logger.printDebug(() -> "Using iOS client for livestream: " + renderer.videoId);
return ClientType.IOS;
}

return ClientType.ANDROID_TESTSUITE;
}

/**
* Injection point.
*/
public static int getClientTypeId(int originalClientTypeId) {
if (SPOOF_CLIENT_ENABLED) {
return getSpoofClientType().id;
}

return originalClientTypeId;
}

/**
* Injection point.
*/
public static String getClientVersion(String originalClientVersion) {
if (SPOOF_CLIENT_ENABLED) {
return getSpoofClientType().version;
}

return originalClientVersion;
}

/**
* Injection point.
*/
public static boolean isClientSpoofingEnabled() {
return SPOOF_CLIENT_ENABLED;
}

//
// Storyboard.
//

/**
* Injection point.
*/
public static String setPlayerResponseVideoId(String parameters, String videoId, boolean isShortAndOpeningOrPlaying) {
if (SPOOF_CLIENT_STORYBOARD) {
try {
// VideoInformation is not a dependent patch, and only this single helper method is used.
// Hook can be called when scrolling thru the feed and a Shorts shelf is present.
// Ignore these videos.
if (!isShortAndOpeningOrPlaying && VideoInformation.playerParametersAreShort(parameters)) {
Logger.printDebug(() -> "Ignoring Short: " + videoId);
return parameters;
}

Future<StoryboardRenderer> storyboard = storyboardCache.get(videoId);
if (storyboard == null) {
storyboard = Utils.submitOnBackgroundThread(() -> getStoryboardRenderer(videoId));
storyboardCache.put(videoId, storyboard);
lastStoryboardFetched = storyboard;

// 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);
} else {
lastStoryboardFetched = storyboard;
// No need to block on the fetch since it previously loaded.
}

} catch (Exception ex) {
Logger.printException(() -> "setPlayerResponseVideoId failure", ex);
}
}

return parameters; // Return the original value since we are observing and not modifying.
}

@Nullable
private static StoryboardRenderer getRenderer(boolean waitForCompletion) {
var future = lastStoryboardFetched;
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;
}

/**
* Injection point.
* Called from background threads and from the main thread.
*/
@Nullable
public static String getStoryboardRendererSpec(String originalStoryboardRendererSpec) {
if (SPOOF_CLIENT_STORYBOARD) {
StoryboardRenderer renderer = getRenderer(false);

if (renderer != null) {
if (!renderer.isLiveStream && renderer.spec != null) {
return renderer.spec;
}
}
}

return originalStoryboardRendererSpec;
}

/**
* Injection point.
*/
public static int getRecommendedLevel(int originalLevel) {
if (SPOOF_CLIENT_STORYBOARD) {
StoryboardRenderer renderer = getRenderer(false);

if (renderer != null) {
if (!renderer.isLiveStream && renderer.recommendedLevel != null) {
return renderer.recommendedLevel;
}
}
}

return originalLevel;
}

private enum ClientType {
ANDROID_TESTSUITE(30, "1.9"),
IOS(5, Utils.getAppVersionName());

final int id;
final String version;

ClientType(int id, String version) {
this.id = id;
this.version = version;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ private static StoryboardRenderer getRenderer(boolean waitForCompletion) {
*
* @param parameters Original protobuf parameter value.
*/
public static String spoofParameter(String parameters, boolean isShortAndOpeningOrPlaying) {
public static String spoofParameter(String parameters, String videoId, boolean isShortAndOpeningOrPlaying) {
try {
Logger.printDebug(() -> "Original protobuf parameter value: " + parameters);

Expand Down Expand Up @@ -152,12 +152,12 @@ private static String getStoryboardRendererSpec(String originalStoryboardRendere
if (Settings.SPOOF_SIGNATURE.get() && !useOriginalStoryboardRenderer) {
StoryboardRenderer renderer = getRenderer(false);
if (renderer != null) {
if (returnNullIfLiveStream && renderer.isLiveStream()) {
if (returnNullIfLiveStream && renderer.isLiveStream) {
return null;
}
String spec = renderer.getSpec();
if (spec != null) {
return spec;

if (renderer.spec != null) {
return renderer.spec;
}
}
}
Expand Down Expand Up @@ -191,8 +191,9 @@ public static int getRecommendedLevel(int originalLevel) {
if (Settings.SPOOF_SIGNATURE.get() && !useOriginalStoryboardRenderer) {
StoryboardRenderer renderer = getRenderer(false);
if (renderer != null) {
Integer recommendedLevel = renderer.getRecommendedLevel();
if (recommendedLevel != null) return recommendedLevel;
if (renderer.recommendedLevel != null) {
return renderer.recommendedLevel;
}
}
}

Expand All @@ -214,7 +215,7 @@ public static boolean getSeekbarThumbnailOverrideValue() {
// Show empty thumbnails so the seek time and chapters still show up.
return true;
}
return renderer.getSpec() != null;
return renderer.spec != null;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,30 @@

import org.jetbrains.annotations.NotNull;

@Deprecated
public final class StoryboardRenderer {
public final String videoId;
@Nullable
private final String spec;
private final boolean isLiveStream;
public final String spec;
public final boolean isLiveStream;
/**
* Recommended image quality level, or NULL if no recommendation exists.
*/
@Nullable
private final Integer recommendedLevel;
public final Integer recommendedLevel;

public StoryboardRenderer(@Nullable String spec, boolean isLiveStream, @Nullable Integer recommendedLevel) {
public StoryboardRenderer(String videoId, @Nullable String spec, boolean isLiveStream, @Nullable Integer recommendedLevel) {
this.videoId = videoId;
this.spec = spec;
this.isLiveStream = isLiveStream;
this.recommendedLevel = recommendedLevel;
}

@Nullable
public String getSpec() {
return spec;
}

public boolean isLiveStream() {
return isLiveStream;
}

/**
* @return Recommended image quality level, or NULL if no recommendation exists.
*/
@Nullable
public Integer getRecommendedLevel() {
return recommendedLevel;
}

@NotNull
@Override
public String toString() {
return "StoryboardRenderer{" +
"isLiveStream=" + isLiveStream +
"videoId=" + videoId +
", isLiveStream=" + isLiveStream +
", spec='" + spec + '\'' +
", recommendedLevel=" + recommendedLevel +
'}';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import java.io.IOException;
import java.net.HttpURLConnection;

@Deprecated
final class PlayerRoutes {
private static final String YT_API_URL = "https://www.youtube.com/youtubei/v1/";
static final Route.CompiledRoute GET_STORYBOARD_SPEC_RENDERER = new Route(
Expand All @@ -27,7 +26,7 @@ final class PlayerRoutes {
/**
* TCP connection and HTTP read timeout
*/
private static final int CONNECTION_TIMEOUT_MILLISECONDS = 4 * 1000; // 4 Seconds.
private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds.

static {
JSONObject innerTubeBody = new JSONObject();
Expand Down

0 comments on commit 4c1f82a

Please sign in to comment.