mirror of
https://github.com/revanced/revanced-integrations.git
synced 2024-11-26 22:06:50 +01:00
fix(YouTube): Fix issues related to playback by replace streaming data (#680)
Co-authored-by: kitadai31 <90122968+kitadai31@users.noreply.github.com> Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
This commit is contained in:
parent
ca50665ac8
commit
04682353af
@ -0,0 +1,79 @@
|
||||
package app.revanced.integrations.youtube.patches.spoof;
|
||||
|
||||
import static app.revanced.integrations.youtube.patches.spoof.DeviceHardwareSupport.allowAV1;
|
||||
import static app.revanced.integrations.youtube.patches.spoof.DeviceHardwareSupport.allowVP9;
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public enum ClientType {
|
||||
// https://dumps.tadiphone.dev/dumps/oculus/eureka
|
||||
IOS(5,
|
||||
// iPhone 15 supports AV1 hardware decoding.
|
||||
// Only use if this Android device also has hardware decoding.
|
||||
allowAV1()
|
||||
? "iPhone16,2" // 15 Pro Max
|
||||
: "iPhone11,4", // XS Max
|
||||
// iOS 14+ forces VP9.
|
||||
allowVP9()
|
||||
? "17.5.1.21F90"
|
||||
: "13.7.17H35",
|
||||
allowVP9()
|
||||
? "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)"
|
||||
: "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 13_7 like Mac OS X)",
|
||||
null,
|
||||
// Version number should be a valid iOS release.
|
||||
// https://www.ipa4fun.com/history/185230
|
||||
"19.10.7"
|
||||
),
|
||||
ANDROID_VR(28,
|
||||
"Quest 3",
|
||||
"12",
|
||||
"com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip",
|
||||
"32", // Android 12.1
|
||||
"1.56.21"
|
||||
);
|
||||
|
||||
/**
|
||||
* YouTube
|
||||
* <a href="https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients">client type</a>
|
||||
*/
|
||||
public final int id;
|
||||
|
||||
/**
|
||||
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
|
||||
*/
|
||||
public final String model;
|
||||
|
||||
/**
|
||||
* Device OS version.
|
||||
*/
|
||||
public final String osVersion;
|
||||
|
||||
/**
|
||||
* Player user-agent.
|
||||
*/
|
||||
public final String userAgent;
|
||||
|
||||
/**
|
||||
* Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk)
|
||||
* Field is null if not applicable.
|
||||
*/
|
||||
@Nullable
|
||||
public final String androidSdkVersion;
|
||||
|
||||
/**
|
||||
* App version.
|
||||
*/
|
||||
public final String appVersion;
|
||||
|
||||
ClientType(int id, String model, String osVersion, String userAgent, @Nullable String androidSdkVersion, String appVersion) {
|
||||
this.id = id;
|
||||
this.model = model;
|
||||
this.osVersion = osVersion;
|
||||
this.userAgent = userAgent;
|
||||
this.androidSdkVersion = androidSdkVersion;
|
||||
this.appVersion = appVersion;
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package app.revanced.integrations.youtube.patches.spoof;
|
||||
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.media.MediaCodecList;
|
||||
import android.os.Build;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.youtube.settings.Settings;
|
||||
|
||||
public class DeviceHardwareSupport {
|
||||
public static final boolean DEVICE_HAS_HARDWARE_DECODING_VP9;
|
||||
public static final boolean DEVICE_HAS_HARDWARE_DECODING_AV1;
|
||||
|
||||
static {
|
||||
boolean vp9found = false;
|
||||
boolean av1found = false;
|
||||
MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
|
||||
final boolean deviceIsAndroidTenOrLater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
|
||||
|
||||
for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) {
|
||||
final boolean isHardwareAccelerated = deviceIsAndroidTenOrLater
|
||||
? codecInfo.isHardwareAccelerated()
|
||||
: !codecInfo.getName().startsWith("OMX.google"); // Software decoder.
|
||||
if (isHardwareAccelerated && !codecInfo.isEncoder()) {
|
||||
for (String type : codecInfo.getSupportedTypes()) {
|
||||
if (type.equalsIgnoreCase("video/x-vnd.on2.vp9")) {
|
||||
vp9found = true;
|
||||
} else if (type.equalsIgnoreCase("video/av01")) {
|
||||
av1found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DEVICE_HAS_HARDWARE_DECODING_VP9 = vp9found;
|
||||
DEVICE_HAS_HARDWARE_DECODING_AV1 = av1found;
|
||||
|
||||
Logger.printDebug(() -> DEVICE_HAS_HARDWARE_DECODING_AV1
|
||||
? "Device supports AV1 hardware decoding\n"
|
||||
: "Device does not support AV1 hardware decoding\n"
|
||||
+ (DEVICE_HAS_HARDWARE_DECODING_VP9
|
||||
? "Device supports VP9 hardware decoding"
|
||||
: "Device does not support VP9 hardware decoding"));
|
||||
}
|
||||
|
||||
public static boolean allowVP9() {
|
||||
return DEVICE_HAS_HARDWARE_DECODING_VP9 && !Settings.SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC.get();
|
||||
}
|
||||
|
||||
public static boolean allowAV1() {
|
||||
return allowVP9() && DEVICE_HAS_HARDWARE_DECODING_AV1;
|
||||
}
|
||||
}
|
@ -1,279 +0,0 @@
|
||||
package app.revanced.integrations.youtube.patches.spoof;
|
||||
|
||||
import static app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch.DeviceHardwareSupport.allowAV1;
|
||||
import static app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch.DeviceHardwareSupport.allowVP9;
|
||||
|
||||
import android.media.MediaCodecInfo;
|
||||
import android.media.MediaCodecList;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
|
||||
import org.chromium.net.ExperimentalUrlRequest;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.settings.Setting;
|
||||
import app.revanced.integrations.youtube.patches.BackgroundPlaybackPatch;
|
||||
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 ClientType SPOOF_CLIENT_TYPE = Settings.SPOOF_CLIENT_TYPE.get();
|
||||
private static final boolean SPOOF_IOS = SPOOF_CLIENT_ENABLED && SPOOF_CLIENT_TYPE == ClientType.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);
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* Blocks /get_watch requests by returning an unreachable URI.
|
||||
*
|
||||
* @param playerRequestUri The URI of the player request.
|
||||
* @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) {
|
||||
try {
|
||||
String path = playerRequestUri.getPath();
|
||||
|
||||
if (path != null && path.contains("get_watch")) {
|
||||
Logger.printDebug(() -> "Blocking 'get_watch' by returning unreachable uri");
|
||||
|
||||
return UNREACHABLE_HOST_URI;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "blockGetWatchRequest failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return playerRequestUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* <p>
|
||||
* Blocks /initplayback requests.
|
||||
*/
|
||||
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")) {
|
||||
Logger.printDebug(() -> "Blocking 'initplayback' by returning unreachable url");
|
||||
|
||||
return UNREACHABLE_HOST_URI_STRING;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return originalUrlString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static int getClientTypeId(int originalClientTypeId) {
|
||||
return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.id : originalClientTypeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static String getClientVersion(String originalClientVersion) {
|
||||
return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.appVersion : originalClientVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static String getClientModel(String originalClientModel) {
|
||||
return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.model : originalClientModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* Fix video qualities missing, if spoofing to iOS by using the correct client OS version.
|
||||
*/
|
||||
public static String getOsVersion(String originalOsVersion) {
|
||||
return SPOOF_CLIENT_ENABLED ? SPOOF_CLIENT_TYPE.osVersion : originalOsVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static boolean enablePlayerGesture(boolean original) {
|
||||
return SPOOF_CLIENT_ENABLED || original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static boolean isClientSpoofingEnabled() {
|
||||
return SPOOF_CLIENT_ENABLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* When spoofing the client to iOS, the playback speed menu is missing from the player response.
|
||||
* Return true to force create the playback speed menu.
|
||||
*/
|
||||
public static boolean forceCreatePlaybackSpeedMenu(boolean original) {
|
||||
return SPOOF_IOS || original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* When spoofing the client to iOS, background audio only playback of livestreams fails.
|
||||
* Return true to force enable audio background play.
|
||||
*/
|
||||
public static boolean overrideBackgroundAudioPlayback() {
|
||||
return SPOOF_IOS && BackgroundPlaybackPatch.playbackIsNotShort();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (SPOOF_CLIENT_ENABLED) {
|
||||
String path = Uri.parse(url).getPath();
|
||||
if (path != null && path.contains("player")) {
|
||||
return builder.addHeader("User-Agent", SPOOF_CLIENT_TYPE.userAgent).build();
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
// Must check for device features in a separate class and cannot place this code inside
|
||||
// the Patch or ClientType enum due to cyclic Setting references.
|
||||
static class DeviceHardwareSupport {
|
||||
private static final boolean DEVICE_HAS_HARDWARE_DECODING_VP9 = deviceHasVP9HardwareDecoding();
|
||||
private static final boolean DEVICE_HAS_HARDWARE_DECODING_AV1 = deviceHasAV1HardwareDecoding();
|
||||
|
||||
private static boolean deviceHasVP9HardwareDecoding() {
|
||||
MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
|
||||
|
||||
for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) {
|
||||
final boolean isHardwareAccelerated = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
? codecInfo.isHardwareAccelerated()
|
||||
: !codecInfo.getName().startsWith("OMX.google"); // Software decoder.
|
||||
if (isHardwareAccelerated && !codecInfo.isEncoder()) {
|
||||
for (String type : codecInfo.getSupportedTypes()) {
|
||||
if (type.equalsIgnoreCase("video/x-vnd.on2.vp9")) {
|
||||
Logger.printDebug(() -> "Device supports VP9 hardware decoding.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.printDebug(() -> "Device does not support VP9 hardware decoding.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean deviceHasAV1HardwareDecoding() {
|
||||
// It appears all devices with hardware AV1 are also Android 10 or newer.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
|
||||
|
||||
for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) {
|
||||
if (codecInfo.isHardwareAccelerated() && !codecInfo.isEncoder()) {
|
||||
for (String type : codecInfo.getSupportedTypes()) {
|
||||
if (type.equalsIgnoreCase("video/av01")) {
|
||||
Logger.printDebug(() -> "Device supports AV1 hardware decoding.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.printDebug(() -> "Device does not support AV1 hardware decoding.");
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean allowVP9() {
|
||||
return DEVICE_HAS_HARDWARE_DECODING_VP9 && !Settings.SPOOF_CLIENT_IOS_FORCE_AVC.get();
|
||||
}
|
||||
|
||||
static boolean allowAV1() {
|
||||
return allowVP9() && DEVICE_HAS_HARDWARE_DECODING_AV1;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ClientType {
|
||||
// https://dumps.tadiphone.dev/dumps/oculus/eureka
|
||||
IOS(5,
|
||||
// iPhone 15 supports AV1 hardware decoding.
|
||||
// Only use if this Android device also has hardware decoding.
|
||||
allowAV1()
|
||||
? "iPhone16,2" // 15 Pro Max
|
||||
: "iPhone11,4", // XS Max
|
||||
// iOS 14+ forces VP9.
|
||||
allowVP9()
|
||||
? "17.5.1.21F90"
|
||||
: "13.7.17H35",
|
||||
allowVP9()
|
||||
? "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)"
|
||||
: "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 13_7 like Mac OS X)",
|
||||
// Version number should be a valid iOS release.
|
||||
// https://www.ipa4fun.com/history/185230
|
||||
"19.10.7"
|
||||
),
|
||||
ANDROID_VR(28,
|
||||
"Quest 3",
|
||||
"12",
|
||||
"com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip",
|
||||
"1.56.21"
|
||||
);
|
||||
|
||||
/**
|
||||
* YouTube
|
||||
* <a href="https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients">client type</a>
|
||||
*/
|
||||
final int id;
|
||||
|
||||
/**
|
||||
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
|
||||
*/
|
||||
final String model;
|
||||
|
||||
/**
|
||||
* Device OS version.
|
||||
*/
|
||||
final String osVersion;
|
||||
|
||||
/**
|
||||
* Player user-agent.
|
||||
*/
|
||||
final String userAgent;
|
||||
|
||||
/**
|
||||
* App version.
|
||||
*/
|
||||
final String appVersion;
|
||||
|
||||
ClientType(int id, String model, String osVersion, String userAgent, String appVersion) {
|
||||
this.id = id;
|
||||
this.model = model;
|
||||
this.osVersion = osVersion;
|
||||
this.userAgent = userAgent;
|
||||
this.appVersion = appVersion;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ForceiOSAVCAvailability implements Setting.Availability {
|
||||
@Override
|
||||
public boolean isAvailable() {
|
||||
return Settings.SPOOF_CLIENT.get() && Settings.SPOOF_CLIENT_TYPE.get() == ClientType.IOS;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,242 +0,0 @@
|
||||
package app.revanced.integrations.youtube.patches.spoof;
|
||||
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import androidx.annotation.Nullable;
|
||||
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;
|
||||
import app.revanced.integrations.youtube.shared.PlayerType;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import static app.revanced.integrations.shared.Utils.containsAny;
|
||||
import static app.revanced.integrations.youtube.patches.spoof.requests.StoryboardRendererRequester.getStoryboardRenderer;
|
||||
|
||||
/** @noinspection unused*/
|
||||
@Deprecated
|
||||
public class SpoofSignaturePatch {
|
||||
/**
|
||||
* Parameter (also used by
|
||||
* <a href="https://github.com/yt-dlp/yt-dlp/blob/81ca451480051d7ce1a31c017e005358345a9149/yt_dlp/extractor/youtube.py#L3602">yt-dlp</a>)
|
||||
* to fix playback issues.
|
||||
*/
|
||||
private static final String INCOGNITO_PARAMETERS = "CgIQBg==";
|
||||
|
||||
/**
|
||||
* Parameters used when playing clips.
|
||||
*/
|
||||
private static final String CLIPS_PARAMETERS = "kAIB";
|
||||
|
||||
/**
|
||||
* Parameters causing playback issues.
|
||||
*/
|
||||
private static final String[] AUTOPLAY_PARAMETERS = {
|
||||
"YAHI", // Autoplay in feed.
|
||||
"SAFg" // Autoplay in scrim.
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameter used for autoplay in scrim.
|
||||
* Prepend this parameter to mute video playback (for autoplay in feed).
|
||||
*/
|
||||
private static final String SCRIM_PARAMETER = "SAFgAXgB";
|
||||
|
||||
/**
|
||||
* Last video id loaded. Used to prevent reloading the same spec multiple times.
|
||||
*/
|
||||
@Nullable
|
||||
private static volatile String lastPlayerResponseVideoId;
|
||||
|
||||
@Nullable
|
||||
private static volatile Future<StoryboardRenderer> rendererFuture;
|
||||
|
||||
private static volatile boolean useOriginalStoryboardRenderer;
|
||||
|
||||
private static volatile boolean isPlayingShorts;
|
||||
|
||||
@Nullable
|
||||
private static StoryboardRenderer getRenderer(boolean waitForCompletion) {
|
||||
Future<StoryboardRenderer> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*
|
||||
* Called off the main thread, and called multiple times for each video.
|
||||
*
|
||||
* @param parameters Original protobuf parameter value.
|
||||
*/
|
||||
public static String spoofParameter(String parameters, String videoId, boolean isShortAndOpeningOrPlaying) {
|
||||
try {
|
||||
Logger.printDebug(() -> "Original protobuf parameter value: " + parameters);
|
||||
|
||||
if (parameters == null || !Settings.SPOOF_SIGNATURE.get()) {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
// Clip's player parameters contain a lot of information (e.g. video start and end time or whether it loops)
|
||||
// For this reason, the player parameters of a clip are usually very long (150~300 characters).
|
||||
// Clips are 60 seconds or less in length, so no spoofing.
|
||||
//noinspection AssignmentUsedAsCondition
|
||||
if (useOriginalStoryboardRenderer = parameters.length() > 150 || parameters.startsWith(CLIPS_PARAMETERS)) {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
// Shorts do not need to be spoofed.
|
||||
//noinspection AssignmentUsedAsCondition
|
||||
if (useOriginalStoryboardRenderer = VideoInformation.playerParametersAreShort(parameters)) {
|
||||
isPlayingShorts = true;
|
||||
return parameters;
|
||||
}
|
||||
isPlayingShorts = false;
|
||||
|
||||
boolean isPlayingFeed = PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL
|
||||
&& containsAny(parameters, AUTOPLAY_PARAMETERS);
|
||||
if (isPlayingFeed) {
|
||||
//noinspection AssignmentUsedAsCondition
|
||||
if (useOriginalStoryboardRenderer = !Settings.SPOOF_SIGNATURE_IN_FEED.get()) {
|
||||
// Don't spoof the feed video playback. This will cause video playback issues,
|
||||
// but only if user continues watching for more than 1 minute.
|
||||
return parameters;
|
||||
}
|
||||
// Spoof the feed video. Video will show up in watch history and video subtitles are missing.
|
||||
fetchStoryboardRenderer();
|
||||
return SCRIM_PARAMETER + INCOGNITO_PARAMETERS;
|
||||
}
|
||||
|
||||
fetchStoryboardRenderer();
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "spoofParameter failure", ex);
|
||||
}
|
||||
return INCOGNITO_PARAMETERS;
|
||||
}
|
||||
|
||||
private static void fetchStoryboardRenderer() {
|
||||
if (!Settings.SPOOF_STORYBOARD_RENDERER.get()) {
|
||||
lastPlayerResponseVideoId = null;
|
||||
rendererFuture = null;
|
||||
return;
|
||||
}
|
||||
String videoId = VideoInformation.getPlayerResponseVideoId();
|
||||
if (!videoId.equals(lastPlayerResponseVideoId)) {
|
||||
rendererFuture = Utils.submitOnBackgroundThread(() -> getStoryboardRenderer(videoId));
|
||||
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);
|
||||
}
|
||||
|
||||
private static String getStoryboardRendererSpec(String originalStoryboardRendererSpec,
|
||||
boolean returnNullIfLiveStream) {
|
||||
if (Settings.SPOOF_SIGNATURE.get() && !useOriginalStoryboardRenderer) {
|
||||
StoryboardRenderer renderer = getRenderer(false);
|
||||
if (renderer != null) {
|
||||
if (returnNullIfLiveStream && renderer.isLiveStream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (renderer.spec != null) {
|
||||
return renderer.spec;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return originalStoryboardRendererSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* Called from background threads and from the main thread.
|
||||
*/
|
||||
@Nullable
|
||||
public static String getStoryboardRendererSpec(String originalStoryboardRendererSpec) {
|
||||
return getStoryboardRendererSpec(originalStoryboardRendererSpec, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* Uses additional check to handle live streams.
|
||||
* Called from background threads and from the main thread.
|
||||
*/
|
||||
@Nullable
|
||||
public static String getStoryboardDecoderRendererSpec(String originalStoryboardRendererSpec) {
|
||||
return getStoryboardRendererSpec(originalStoryboardRendererSpec, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static int getRecommendedLevel(int originalLevel) {
|
||||
if (Settings.SPOOF_SIGNATURE.get() && !useOriginalStoryboardRenderer) {
|
||||
StoryboardRenderer renderer = getRenderer(false);
|
||||
if (renderer != null) {
|
||||
if (renderer.recommendedLevel != null) {
|
||||
return renderer.recommendedLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return originalLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point. Forces seekbar to be shown for paid videos or
|
||||
* if {@link Settings#SPOOF_STORYBOARD_RENDERER} is not enabled.
|
||||
*/
|
||||
public static boolean getSeekbarThumbnailOverrideValue() {
|
||||
if (!Settings.SPOOF_SIGNATURE.get()) {
|
||||
return false;
|
||||
}
|
||||
StoryboardRenderer renderer = getRenderer(false);
|
||||
if (renderer == null) {
|
||||
// Spoof storyboard renderer is turned off,
|
||||
// video is paid, or the storyboard fetch timed out.
|
||||
// Show empty thumbnails so the seek time and chapters still show up.
|
||||
return true;
|
||||
}
|
||||
return renderer.spec != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*
|
||||
* @param view seekbar thumbnail view. Includes both shorts and regular videos.
|
||||
*/
|
||||
public static void seekbarImageViewCreated(ImageView view) {
|
||||
try {
|
||||
if (!Settings.SPOOF_SIGNATURE.get()
|
||||
|| Settings.SPOOF_STORYBOARD_RENDERER.get()) {
|
||||
return;
|
||||
}
|
||||
if (isPlayingShorts) return;
|
||||
|
||||
view.setVisibility(View.GONE);
|
||||
// Also hide the border around the thumbnail (otherwise a 1 pixel wide bordered frame is visible).
|
||||
ViewGroup parentLayout = (ViewGroup) view.getParent();
|
||||
parentLayout.setPadding(0, 0, 0, 0);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "seekbarImageViewCreated failure", ex);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,170 @@
|
||||
package app.revanced.integrations.youtube.patches.spoof;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
import app.revanced.integrations.shared.settings.BaseSettings;
|
||||
import app.revanced.integrations.shared.settings.Setting;
|
||||
import app.revanced.integrations.youtube.patches.spoof.requests.StreamingDataRequest;
|
||||
import app.revanced.integrations.youtube.settings.Settings;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class SpoofVideoStreamsPatch {
|
||||
public static final class ForceiOSAVCAvailability implements Setting.Availability {
|
||||
@Override
|
||||
public boolean isAvailable() {
|
||||
return Settings.SPOOF_VIDEO_STREAMS.get() && Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS;
|
||||
}
|
||||
}
|
||||
|
||||
private static final boolean SPOOF_STREAMING_DATA = Settings.SPOOF_VIDEO_STREAMS.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);
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* Blocks /get_watch requests by returning an unreachable URI.
|
||||
*
|
||||
* @param playerRequestUri The URI of the player request.
|
||||
* @return An unreachable URI if the request is a /get_watch request, otherwise the original URI.
|
||||
*/
|
||||
public static Uri blockGetWatchRequest(Uri playerRequestUri) {
|
||||
if (SPOOF_STREAMING_DATA) {
|
||||
try {
|
||||
String path = playerRequestUri.getPath();
|
||||
|
||||
if (path != null && path.contains("get_watch")) {
|
||||
Logger.printDebug(() -> "Blocking 'get_watch' by returning unreachable uri");
|
||||
|
||||
return UNREACHABLE_HOST_URI;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "blockGetWatchRequest failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return playerRequestUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* <p>
|
||||
* Blocks /initplayback requests.
|
||||
*/
|
||||
public static String blockInitPlaybackRequest(String originalUrlString) {
|
||||
if (SPOOF_STREAMING_DATA) {
|
||||
try {
|
||||
var originalUri = Uri.parse(originalUrlString);
|
||||
String path = originalUri.getPath();
|
||||
|
||||
if (path != null && path.contains("initplayback")) {
|
||||
Logger.printDebug(() -> "Blocking 'initplayback' by returning unreachable url");
|
||||
|
||||
return UNREACHABLE_HOST_URI_STRING;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return originalUrlString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static boolean isSpoofingEnabled() {
|
||||
return SPOOF_STREAMING_DATA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static void fetchStreams(String url, Map<String, String> requestHeaders) {
|
||||
if (SPOOF_STREAMING_DATA) {
|
||||
try {
|
||||
Uri uri = Uri.parse(url);
|
||||
String path = uri.getPath();
|
||||
// 'heartbeat' has no video id and appears to be only after playback has started.
|
||||
if (path != null && path.contains("player") && !path.contains("heartbeat")) {
|
||||
String videoId = Objects.requireNonNull(uri.getQueryParameter("id"));
|
||||
StreamingDataRequest.fetchRequest(videoId, requestHeaders);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "buildRequest failure", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* Fix playback by replace the streaming data.
|
||||
* Called after {@link #fetchStreams(String, Map)}.
|
||||
*/
|
||||
@Nullable
|
||||
public static ByteBuffer getStreamingData(String videoId) {
|
||||
if (SPOOF_STREAMING_DATA) {
|
||||
try {
|
||||
StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId);
|
||||
if (request != null) {
|
||||
// This hook is always called off the main thread,
|
||||
// but this can later be called for the same video id from the main thread.
|
||||
// This is not a concern, since the fetch will always be finished
|
||||
// and never block the main thread.
|
||||
// But if debugging, then still verify this is the situation.
|
||||
if (BaseSettings.DEBUG.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) {
|
||||
Logger.printException(() -> "Error: Blocking main thread");
|
||||
}
|
||||
|
||||
var stream = request.getStream();
|
||||
if (stream != null) {
|
||||
Logger.printDebug(() -> "Overriding video stream: " + videoId);
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.printDebug(() -> "Not overriding streaming data (video stream is null): " + videoId);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "getStreamingData failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
* Called after {@link #getStreamingData(String)}.
|
||||
*/
|
||||
@Nullable
|
||||
public static byte[] removeVideoPlaybackPostBody(Uri uri, int method, byte[] postData) {
|
||||
if (SPOOF_STREAMING_DATA) {
|
||||
try {
|
||||
final int methodPost = 2;
|
||||
if (method == methodPost) {
|
||||
String path = uri.getPath();
|
||||
String clientNameQueryKey = "c";
|
||||
final boolean iosClient = "IOS".equals(uri.getQueryParameter(clientNameQueryKey));
|
||||
if (iosClient && path != null && path.contains("videoplayback")) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return postData;
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
package app.revanced.integrations.youtube.patches.spoof;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
@Deprecated
|
||||
public final class StoryboardRenderer {
|
||||
public final String videoId;
|
||||
@Nullable
|
||||
public final String spec;
|
||||
public final boolean isLiveStream;
|
||||
/**
|
||||
* Recommended image quality level, or NULL if no recommendation exists.
|
||||
*/
|
||||
@Nullable
|
||||
public final 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;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "StoryboardRenderer{" +
|
||||
"videoId=" + videoId +
|
||||
", isLiveStream=" + isLiveStream +
|
||||
", spec='" + spec + '\'' +
|
||||
", recommendedLevel=" + recommendedLevel +
|
||||
'}';
|
||||
}
|
||||
}
|
@ -1,94 +1,68 @@
|
||||
package app.revanced.integrations.youtube.patches.spoof.requests;
|
||||
|
||||
import app.revanced.integrations.youtube.requests.Requester;
|
||||
import app.revanced.integrations.youtube.requests.Route;
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.youtube.patches.spoof.ClientType;
|
||||
import app.revanced.integrations.youtube.requests.Requester;
|
||||
import app.revanced.integrations.youtube.requests.Route;
|
||||
|
||||
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(
|
||||
private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/";
|
||||
|
||||
static final Route.CompiledRoute GET_STREAMING_DATA = new Route(
|
||||
Route.Method.POST,
|
||||
"player" +
|
||||
"?fields=storyboards.playerStoryboardSpecRenderer," +
|
||||
"storyboards.playerLiveStoryboardSpecRenderer," +
|
||||
"playabilityStatus.status"
|
||||
"?fields=streamingData" +
|
||||
"&alt=proto"
|
||||
).compile();
|
||||
|
||||
static final String ANDROID_INNER_TUBE_BODY;
|
||||
static final String TV_EMBED_INNER_TUBE_BODY;
|
||||
|
||||
/**
|
||||
* TCP connection and HTTP read timeout
|
||||
*/
|
||||
private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds.
|
||||
|
||||
static {
|
||||
JSONObject innerTubeBody = new JSONObject();
|
||||
private PlayerRoutes() {
|
||||
}
|
||||
|
||||
static String createInnertubeBody(ClientType clientType) {
|
||||
JSONObject innerTubeBody = new JSONObject();
|
||||
|
||||
try {
|
||||
JSONObject context = new JSONObject();
|
||||
|
||||
JSONObject client = new JSONObject();
|
||||
client.put("clientName", "ANDROID");
|
||||
client.put("clientVersion", Utils.getAppVersionName());
|
||||
client.put("androidSdkVersion", 34);
|
||||
client.put("clientName", clientType.name());
|
||||
client.put("clientVersion", clientType.appVersion);
|
||||
client.put("deviceModel", clientType.model);
|
||||
client.put("osVersion", clientType.osVersion);
|
||||
if (clientType.androidSdkVersion != null) {
|
||||
client.put("androidSdkVersion", clientType.androidSdkVersion);
|
||||
}
|
||||
|
||||
context.put("client", client);
|
||||
|
||||
innerTubeBody.put("context", context);
|
||||
innerTubeBody.put("contentCheckOk", true);
|
||||
innerTubeBody.put("racyCheckOk", true);
|
||||
innerTubeBody.put("videoId", "%s");
|
||||
} catch (JSONException e) {
|
||||
Logger.printException(() -> "Failed to create innerTubeBody", e);
|
||||
}
|
||||
|
||||
ANDROID_INNER_TUBE_BODY = innerTubeBody.toString();
|
||||
|
||||
JSONObject tvEmbedInnerTubeBody = new JSONObject();
|
||||
|
||||
try {
|
||||
JSONObject context = new JSONObject();
|
||||
|
||||
JSONObject client = new JSONObject();
|
||||
client.put("clientName", "TVHTML5_SIMPLY_EMBEDDED_PLAYER");
|
||||
client.put("clientVersion", "2.0");
|
||||
client.put("platform", "TV");
|
||||
client.put("clientScreen", "EMBED");
|
||||
|
||||
JSONObject thirdParty = new JSONObject();
|
||||
thirdParty.put("embedUrl", "https://www.youtube.com/watch?v=%s");
|
||||
|
||||
context.put("thirdParty", thirdParty);
|
||||
context.put("client", client);
|
||||
|
||||
tvEmbedInnerTubeBody.put("context", context);
|
||||
tvEmbedInnerTubeBody.put("videoId", "%s");
|
||||
} catch (JSONException e) {
|
||||
Logger.printException(() -> "Failed to create tvEmbedInnerTubeBody", e);
|
||||
}
|
||||
|
||||
TV_EMBED_INNER_TUBE_BODY = tvEmbedInnerTubeBody.toString();
|
||||
}
|
||||
|
||||
private PlayerRoutes() {
|
||||
return innerTubeBody.toString();
|
||||
}
|
||||
|
||||
/** @noinspection SameParameterValue*/
|
||||
static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route) throws IOException {
|
||||
static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException {
|
||||
var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route);
|
||||
|
||||
connection.setRequestProperty(
|
||||
"User-Agent", "com.google.android.youtube/" +
|
||||
Utils.getAppVersionName() +
|
||||
" (Linux; U; Android 12; GB) gzip"
|
||||
);
|
||||
connection.setRequestProperty("X-Goog-Api-Format-Version", "2");
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setRequestProperty("User-Agent", clientType.userAgent);
|
||||
|
||||
connection.setUseCaches(false);
|
||||
connection.setDoOutput(true);
|
||||
|
@ -1,153 +0,0 @@
|
||||
package app.revanced.integrations.youtube.patches.spoof.requests;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import app.revanced.integrations.shared.settings.BaseSettings;
|
||||
import app.revanced.integrations.youtube.patches.spoof.StoryboardRenderer;
|
||||
import app.revanced.integrations.youtube.requests.Requester;
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
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 static app.revanced.integrations.shared.StringRef.str;
|
||||
import static app.revanced.integrations.youtube.patches.spoof.requests.PlayerRoutes.*;
|
||||
|
||||
public class StoryboardRendererRequester {
|
||||
|
||||
private StoryboardRendererRequester() {
|
||||
}
|
||||
|
||||
private static void randomlyWaitIfLocallyDebugging() {
|
||||
final boolean randomlyWait = false; // Enable to simulate slow connection responses.
|
||||
if (randomlyWait) {
|
||||
final long maximumTimeToRandomlyWait = 10000;
|
||||
Utils.doNothingForDuration(maximumTimeToRandomlyWait);
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex,
|
||||
boolean showToastOnIOException) {
|
||||
if (showToastOnIOException) Utils.showToastShort(toastMessage);
|
||||
Logger.printInfo(() -> toastMessage, ex);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static JSONObject fetchPlayerResponse(@NonNull String requestBody, boolean showToastOnIOException) {
|
||||
final long startTime = System.currentTimeMillis();
|
||||
try {
|
||||
Utils.verifyOffMainThread();
|
||||
Objects.requireNonNull(requestBody);
|
||||
|
||||
final byte[] innerTubeBody = requestBody.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STORYBOARD_SPEC_RENDERER);
|
||||
connection.getOutputStream().write(innerTubeBody, 0, innerTubeBody.length);
|
||||
|
||||
final int responseCode = connection.getResponseCode();
|
||||
randomlyWaitIfLocallyDebugging();
|
||||
if (responseCode == 200) return Requester.parseJSONObject(connection);
|
||||
|
||||
// Always show a toast for this, as a non 200 response means something is broken.
|
||||
// Not a normal code path and should not be reached, so no translations are needed.
|
||||
handleConnectionError("Spoof storyboard not available: " + responseCode,
|
||||
null, showToastOnIOException || BaseSettings.DEBUG_TOAST_ON_ERROR.get());
|
||||
connection.disconnect();
|
||||
} catch (SocketTimeoutException ex) {
|
||||
handleConnectionError(str("revanced_spoof_client_storyboard_timeout"), ex, showToastOnIOException);
|
||||
} catch (IOException ex) {
|
||||
handleConnectionError(str("revanced_spoof_client_storyboard_io_exception", ex.getMessage()),
|
||||
ex, showToastOnIOException);
|
||||
} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the storyboardRenderer from the innerTubeBody.
|
||||
* @param innerTubeBody The innerTubeBody to use to fetch the storyboardRenderer.
|
||||
* @return StoryboardRenderer or null if playabilityStatus is not OK.
|
||||
*/
|
||||
@Nullable
|
||||
private static StoryboardRenderer getStoryboardRendererUsingBody(String videoId,
|
||||
@NonNull String innerTubeBody,
|
||||
boolean showToastOnIOException) {
|
||||
final JSONObject playerResponse = fetchPlayerResponse(innerTubeBody, showToastOnIOException);
|
||||
if (playerResponse != null && isPlayabilityStatusOk(playerResponse))
|
||||
return getStoryboardRendererUsingResponse(videoId, playerResponse);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static StoryboardRenderer getStoryboardRendererUsingResponse(@NonNull String videoId, @NonNull JSONObject playerResponse) {
|
||||
try {
|
||||
Logger.printDebug(() -> "Parsing response: " + playerResponse);
|
||||
if (!playerResponse.has("storyboards")) {
|
||||
Logger.printDebug(() -> "Using empty storyboard");
|
||||
return new StoryboardRenderer(videoId, null, false, null);
|
||||
}
|
||||
final JSONObject storyboards = playerResponse.getJSONObject("storyboards");
|
||||
final boolean isLiveStream = storyboards.has("playerLiveStoryboardSpecRenderer");
|
||||
final String storyboardsRendererTag = isLiveStream
|
||||
? "playerLiveStoryboardSpecRenderer"
|
||||
: "playerStoryboardSpecRenderer";
|
||||
|
||||
final var rendererElement = storyboards.getJSONObject(storyboardsRendererTag);
|
||||
StoryboardRenderer renderer = new StoryboardRenderer(
|
||||
videoId,
|
||||
rendererElement.getString("spec"),
|
||||
isLiveStream,
|
||||
rendererElement.has("recommendedLevel")
|
||||
? rendererElement.getInt("recommendedLevel")
|
||||
: null
|
||||
);
|
||||
|
||||
Logger.printDebug(() -> "Fetched: " + renderer);
|
||||
|
||||
return renderer;
|
||||
} catch (JSONException e) {
|
||||
Logger.printException(() -> "Failed to get storyboardRenderer", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static StoryboardRenderer getStoryboardRenderer(@NonNull String videoId) {
|
||||
Objects.requireNonNull(videoId);
|
||||
|
||||
var renderer = getStoryboardRendererUsingBody(videoId,
|
||||
String.format(ANDROID_INNER_TUBE_BODY, videoId), false);
|
||||
if (renderer == null) {
|
||||
Logger.printDebug(() -> videoId + " not available using Android client");
|
||||
renderer = getStoryboardRendererUsingBody(videoId,
|
||||
String.format(TV_EMBED_INNER_TUBE_BODY, videoId, videoId), true);
|
||||
if (renderer == null) {
|
||||
Logger.printDebug(() -> videoId + " not available using TV embedded client");
|
||||
}
|
||||
}
|
||||
|
||||
return renderer;
|
||||
}
|
||||
}
|
@ -0,0 +1,215 @@
|
||||
package app.revanced.integrations.youtube.patches.spoof.requests;
|
||||
|
||||
import static app.revanced.integrations.youtube.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
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.shared.settings.BaseSettings;
|
||||
import app.revanced.integrations.youtube.patches.spoof.ClientType;
|
||||
import app.revanced.integrations.youtube.settings.Settings;
|
||||
|
||||
/**
|
||||
* Video streaming data. Fetching is tied to the behavior YT uses,
|
||||
* where this class fetches the streams only when YT fetches.
|
||||
*
|
||||
* Effectively the cache expiration of these fetches is the same as the stock app,
|
||||
* since the stock app would not use expired streams and therefor
|
||||
* the integrations replace stream hook is called only if YT
|
||||
* would have used it's own client streams.
|
||||
*/
|
||||
public class StreamingDataRequest {
|
||||
|
||||
private static final ClientType[] CLIENT_ORDER_TO_USE;
|
||||
|
||||
static {
|
||||
ClientType[] allClientTypes = ClientType.values();
|
||||
ClientType preferredClient = Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
|
||||
|
||||
CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length];
|
||||
CLIENT_ORDER_TO_USE[0] = preferredClient;
|
||||
|
||||
int i = 1;
|
||||
for (ClientType c : allClientTypes) {
|
||||
if (c != preferredClient) {
|
||||
CLIENT_ORDER_TO_USE[i++] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TCP connection and HTTP read timeout.
|
||||
*/
|
||||
private static final int HTTP_TIMEOUT_MILLISECONDS = 10 * 1000;
|
||||
|
||||
/**
|
||||
* Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS}
|
||||
*/
|
||||
private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000;
|
||||
|
||||
private static final Map<String, StreamingDataRequest> cache = Collections.synchronizedMap(
|
||||
new LinkedHashMap<>(100) {
|
||||
/**
|
||||
* Cache limit must be greater than the maximum number of videos open at once,
|
||||
* which theoretically is more than 4 (3 Shorts + one regular minimized video).
|
||||
* But instead use a much larger value, to handle if a video viewed a while ago
|
||||
* is somehow still referenced. Each stream is a small array of Strings
|
||||
* so memory usage is not a concern.
|
||||
*/
|
||||
private static final int CACHE_LIMIT = 50;
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Entry eldest) {
|
||||
return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
|
||||
}
|
||||
});
|
||||
|
||||
public static void fetchRequest(String videoId, Map<String, String> fetchHeaders) {
|
||||
// Always fetch, even if there is a existing request for the same video.
|
||||
cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static StreamingDataRequest getRequestForVideoId(String videoId) {
|
||||
return cache.get(videoId);
|
||||
}
|
||||
|
||||
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(ClientType clientType, String videoId,
|
||||
Map<String, String> playerHeaders,
|
||||
boolean showErrorToasts) {
|
||||
Objects.requireNonNull(clientType);
|
||||
Objects.requireNonNull(videoId);
|
||||
Objects.requireNonNull(playerHeaders);
|
||||
|
||||
final long startTime = System.currentTimeMillis();
|
||||
String clientTypeName = clientType.name();
|
||||
Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType.name());
|
||||
|
||||
try {
|
||||
HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType);
|
||||
connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
|
||||
connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
|
||||
|
||||
String authHeader = playerHeaders.get("Authorization");
|
||||
String visitorId = playerHeaders.get("X-Goog-Visitor-Id");
|
||||
connection.setRequestProperty("Authorization", authHeader);
|
||||
connection.setRequestProperty("X-Goog-Visitor-Id", visitorId);
|
||||
|
||||
String innerTubeBody = String.format(PlayerRoutes.createInnertubeBody(clientType), videoId);
|
||||
byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8);
|
||||
connection.setFixedLengthStreamingMode(requestBody.length);
|
||||
connection.getOutputStream().write(requestBody);
|
||||
|
||||
final int responseCode = connection.getResponseCode();
|
||||
if (responseCode == 200) return connection;
|
||||
|
||||
handleConnectionError(clientTypeName + " not available with response code: "
|
||||
+ responseCode + " message: " + connection.getResponseMessage(),
|
||||
null, showErrorToasts);
|
||||
} catch (SocketTimeoutException ex) {
|
||||
handleConnectionError("Connection timeout", ex, showErrorToasts);
|
||||
} catch (IOException ex) {
|
||||
handleConnectionError("Network error", ex, showErrorToasts);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "send failed", ex);
|
||||
} finally {
|
||||
Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ByteBuffer fetch(String videoId, Map<String, String> playerHeaders) {
|
||||
final boolean debugEnabled = BaseSettings.DEBUG.get();
|
||||
|
||||
// Retry with different client if empty response body is received.
|
||||
int i = 0;
|
||||
for (ClientType clientType : CLIENT_ORDER_TO_USE) {
|
||||
// Show an error if the last client type fails, or if the debug is enabled then show for all attempts.
|
||||
final boolean showErrorToast = (++i == CLIENT_ORDER_TO_USE.length) || debugEnabled;
|
||||
|
||||
HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast);
|
||||
if (connection != null) {
|
||||
try {
|
||||
// gzip encoding doesn't response with content length (-1),
|
||||
// but empty response body does.
|
||||
if (connection.getContentLength() != 0) {
|
||||
try (InputStream inputStream = new BufferedInputStream(connection.getInputStream())) {
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[2048];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) >= 0) {
|
||||
baos.write(buffer, 0, bytesRead);
|
||||
}
|
||||
|
||||
return ByteBuffer.wrap(baos.toByteArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
Logger.printException(() -> "Fetch failed while processing response data", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionError("Could not fetch any client streams", null, debugEnabled);
|
||||
return null;
|
||||
}
|
||||
|
||||
private final String videoId;
|
||||
private final Future<ByteBuffer> future;
|
||||
|
||||
private StreamingDataRequest(String videoId, Map<String, String> playerHeaders) {
|
||||
Objects.requireNonNull(playerHeaders);
|
||||
this.videoId = videoId;
|
||||
this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders));
|
||||
}
|
||||
|
||||
public boolean fetchCompleted() {
|
||||
return future.isDone();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public ByteBuffer getStream() {
|
||||
try {
|
||||
return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException ex) {
|
||||
Logger.printInfo(() -> "getStream timed out", ex);
|
||||
} catch (InterruptedException ex) {
|
||||
Logger.printException(() -> "getStream interrupted", ex);
|
||||
Thread.currentThread().interrupt(); // Restore interrupt status flag.
|
||||
} catch (ExecutionException ex) {
|
||||
Logger.printException(() -> "getStream failure", ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "StreamingDataRequest{" + "videoId='" + videoId + '\'' + '}';
|
||||
}
|
||||
}
|
@ -7,8 +7,9 @@ import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.DeAr
|
||||
import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.StillImagesAvailability;
|
||||
import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.ThumbnailOption;
|
||||
import app.revanced.integrations.youtube.patches.AlternativeThumbnailsPatch.ThumbnailStillTime;
|
||||
import app.revanced.integrations.youtube.patches.spoof.ClientType;
|
||||
import app.revanced.integrations.youtube.patches.spoof.SpoofAppVersionPatch;
|
||||
import app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch;
|
||||
import app.revanced.integrations.youtube.patches.spoof.SpoofVideoStreamsPatch;
|
||||
import app.revanced.integrations.youtube.sponsorblock.SponsorBlockSettings;
|
||||
|
||||
import java.util.Arrays;
|
||||
@ -19,7 +20,6 @@ import static app.revanced.integrations.shared.settings.Setting.*;
|
||||
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType;
|
||||
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_1;
|
||||
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_3;
|
||||
import static app.revanced.integrations.youtube.patches.spoof.SpoofClientPatch.ClientType;
|
||||
import static app.revanced.integrations.youtube.sponsorblock.objects.CategoryBehaviour.*;
|
||||
import static java.lang.Boolean.FALSE;
|
||||
import static java.lang.Boolean.TRUE;
|
||||
@ -256,10 +256,10 @@ public class Settings extends BaseSettings {
|
||||
"revanced_spoof_device_dimensions_user_dialog_message");
|
||||
public static final BooleanSetting BYPASS_URL_REDIRECTS = new BooleanSetting("revanced_bypass_url_redirects", TRUE);
|
||||
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_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_client_ios_force_avc", FALSE, true,
|
||||
"revanced_spoof_client_ios_force_avc_user_dialog_message", new SpoofClientPatch.ForceiOSAVCAvailability());
|
||||
public static final EnumSetting<ClientType> SPOOF_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_client_type", ClientType.IOS, true, parent(SPOOF_CLIENT));
|
||||
public static final BooleanSetting SPOOF_VIDEO_STREAMS = new BooleanSetting("revanced_spoof_video_streams", TRUE, true,"revanced_spoof_video_streams_user_dialog_message");
|
||||
public static final BooleanSetting SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_video_streams_ios_force_avc", FALSE, true,
|
||||
"revanced_spoof_video_streams_ios_force_avc_user_dialog_message", new SpoofVideoStreamsPatch.ForceiOSAVCAvailability());
|
||||
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client_type", ClientType.IOS, true, parent(SPOOF_VIDEO_STREAMS));
|
||||
@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);
|
||||
|
@ -0,0 +1,61 @@
|
||||
package app.revanced.integrations.youtube.settings.preference;
|
||||
|
||||
import static app.revanced.integrations.shared.StringRef.str;
|
||||
import static app.revanced.integrations.youtube.patches.spoof.DeviceHardwareSupport.DEVICE_HAS_HARDWARE_DECODING_VP9;
|
||||
|
||||
import android.content.Context;
|
||||
import android.preference.SwitchPreference;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
@SuppressWarnings({"unused", "deprecation"})
|
||||
public class ForceAVCSpoofingPreference extends SwitchPreference {
|
||||
{
|
||||
if (!DEVICE_HAS_HARDWARE_DECODING_VP9) {
|
||||
setSummaryOn(str("revanced_spoof_video_streams_ios_force_avc_no_hardware_vp9_summary_on"));
|
||||
}
|
||||
}
|
||||
|
||||
public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
}
|
||||
public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
public ForceAVCSpoofingPreference(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
public ForceAVCSpoofingPreference(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
private void updateUI() {
|
||||
if (DEVICE_HAS_HARDWARE_DECODING_VP9) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporarily remove the preference key to allow changing this preference without
|
||||
// causing the settings UI listeners from showing reboot dialogs by the changes made here.
|
||||
String key = getKey();
|
||||
setKey(null);
|
||||
|
||||
// This setting cannot be changed by the user.
|
||||
super.setEnabled(false);
|
||||
super.setChecked(true);
|
||||
|
||||
setKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEnabled(boolean enabled) {
|
||||
super.setEnabled(enabled);
|
||||
|
||||
updateUI();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setChecked(boolean checked) {
|
||||
super.setChecked(checked);
|
||||
|
||||
updateUI();
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
package org.chromium.net;
|
||||
|
||||
public abstract class ExperimentalUrlRequest {
|
||||
public abstract class Builder {
|
||||
public abstract ExperimentalUrlRequest.Builder addHeader(String name, String value);
|
||||
public abstract ExperimentalUrlRequest build();
|
||||
}
|
||||
}
|
@ -1,4 +1,8 @@
|
||||
package org.chromium.net;
|
||||
|
||||
public abstract class UrlRequest {
|
||||
public abstract class Builder {
|
||||
public abstract Builder addHeader(String name, String value);
|
||||
public abstract UrlRequest build();
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user