From 01019b09c1c106ed814b994dd8af558a18873c1d Mon Sep 17 00:00:00 2001 From: oSumAtrIX Date: Tue, 26 Sep 2023 04:43:12 +0200 Subject: [PATCH] fix(YouTube - Client spoof): Show seekbar thumbnail for age restricted and paid videos --- .../patches/spoof/SpoofSignaturePatch.java | 51 ++++---- .../patches/spoof/requests/PlayerRoutes.java | 64 +++++++++- .../requests/StoryBoardRendererRequester.java | 80 ------------- .../requests/StoryboardRendererRequester.java | 113 ++++++++++++++++++ 4 files changed, 197 insertions(+), 111 deletions(-) delete mode 100644 app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryBoardRendererRequester.java create mode 100644 app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryboardRendererRequester.java diff --git a/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofSignaturePatch.java b/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofSignaturePatch.java index b84c6636..452150e1 100644 --- a/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofSignaturePatch.java +++ b/app/src/main/java/app/revanced/integrations/patches/spoof/SpoofSignaturePatch.java @@ -1,20 +1,19 @@ package app.revanced.integrations.patches.spoof; -import static app.revanced.integrations.patches.spoof.requests.StoryBoardRendererRequester.fetchStoryboardRenderer; -import static app.revanced.integrations.utils.ReVancedUtils.containsAny; - import androidx.annotation.Nullable; +import app.revanced.integrations.patches.VideoInformation; +import app.revanced.integrations.settings.SettingsEnum; +import app.revanced.integrations.shared.PlayerType; +import app.revanced.integrations.utils.LogHelper; +import app.revanced.integrations.utils.ReVancedUtils; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import app.revanced.integrations.patches.VideoInformation; -import app.revanced.integrations.settings.SettingsEnum; -import app.revanced.integrations.shared.PlayerType; -import app.revanced.integrations.utils.LogHelper; -import app.revanced.integrations.utils.ReVancedUtils; +import static app.revanced.integrations.patches.spoof.requests.StoryboardRendererRequester.getStoryboardRenderer; +import static app.revanced.integrations.utils.ReVancedUtils.containsAny; /** @noinspection unused*/ public class SpoofSignaturePatch { @@ -51,6 +50,24 @@ public class SpoofSignaturePatch { private static volatile Future rendererFuture; + @Nullable + private static volatile StoryboardRenderer renderer; + + @Nullable + private static StoryboardRenderer getRenderer() { + if (rendererFuture != null) { + try { + return rendererFuture.get(5000, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + LogHelper.printDebug(() -> "Could not get renderer (get timed out)"); + } catch (ExecutionException | InterruptedException ex) { + // Should never happen. + LogHelper.printException(() -> "Could not get renderer", ex); + } + } + return null; + } + /** * Injection point. * @@ -83,27 +100,12 @@ public class SpoofSignaturePatch { String videoId = VideoInformation.getVideoId(); if (!videoId.equals(currentVideoId)) { currentVideoId = videoId; - rendererFuture = ReVancedUtils.submitOnBackgroundThread(() -> fetchStoryboardRenderer(videoId)); + rendererFuture = ReVancedUtils.submitOnBackgroundThread(() -> getStoryboardRenderer(videoId)); } return INCOGNITO_PARAMETERS; } - @Nullable - private static StoryboardRenderer getRenderer() { - if (rendererFuture != null) { - try { - return rendererFuture.get(5000, TimeUnit.MILLISECONDS); - } catch (TimeoutException ex) { - LogHelper.printDebug(() -> "Could not get renderer (get timed out)"); - } catch (ExecutionException | InterruptedException ex) { - // Should never happen. - LogHelper.printException(() -> "Could not get renderer", ex); - } - } - return null; - } - /** * Injection point. */ @@ -136,5 +138,4 @@ public class SpoofSignaturePatch { return renderer.getRecommendedLevel(); } - } diff --git a/app/src/main/java/app/revanced/integrations/patches/spoof/requests/PlayerRoutes.java b/app/src/main/java/app/revanced/integrations/patches/spoof/requests/PlayerRoutes.java index e6b7a1f6..db3971ba 100644 --- a/app/src/main/java/app/revanced/integrations/patches/spoof/requests/PlayerRoutes.java +++ b/app/src/main/java/app/revanced/integrations/patches/spoof/requests/PlayerRoutes.java @@ -2,19 +2,73 @@ package app.revanced.integrations.patches.spoof.requests; import app.revanced.integrations.requests.Requester; import app.revanced.integrations.requests.Route; +import app.revanced.integrations.utils.LogHelper; +import org.json.JSONException; +import org.json.JSONObject; import java.io.IOException; import java.net.HttpURLConnection; final class PlayerRoutes { private static final String YT_API_URL = "https://www.youtube.com/youtubei/v1/"; - static final Route.CompiledRoute POST_STORYBOARD_SPEC_RENDERER = new Route( + static final Route.CompiledRoute GET_STORYBOARD_SPEC_RENDERER = new Route( Route.Method.POST, "player" + "?fields=storyboards.playerStoryboardSpecRenderer," + - "storyboards.playerLiveStoryboardSpecRenderer" + "storyboards.playerLiveStoryboardSpecRenderer," + + "playabilityStatus.status" ).compile(); + static final String ANDROID_INNER_TUBE_BODY; + static final String TV_EMBED_INNER_TUBE_BODY; + + static { + JSONObject innerTubeBody = new JSONObject(); + + try { + JSONObject context = new JSONObject(); + + JSONObject client = new JSONObject(); + client.put("clientName", "ANDROID"); + client.put("clientVersion", "18.37.36"); + client.put("androidSdkVersion", 34); + + context.put("client", client); + + innerTubeBody.put("context", context); + innerTubeBody.put("videoId", "%s"); + } catch (JSONException e) { + LogHelper.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) { + LogHelper.printException(() -> "Failed to create tvEmbedInnerTubeBody", e); + } + + TV_EMBED_INNER_TUBE_BODY = tvEmbedInnerTubeBody.toString(); + } + private PlayerRoutes() { } @@ -24,14 +78,12 @@ final class PlayerRoutes { connection.setRequestProperty("User-Agent", "com.google.android.youtube/18.37.36 (Linux; U; Android 12; GB) gzip"); connection.setRequestProperty("X-Goog-Api-Format-Version", "2"); connection.setRequestProperty("Content-Type", "application/json"); - connection.setRequestProperty("Accept-Language", "en-GB, en;q=0.9"); - connection.setRequestProperty("Pragma", "no-cache"); - connection.setRequestProperty("Cache-Control", "no-cache"); + connection.setUseCaches(false); connection.setDoOutput(true); + connection.setConnectTimeout(5000); connection.setReadTimeout(5000); return connection; } - } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryBoardRendererRequester.java b/app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryBoardRendererRequester.java deleted file mode 100644 index 2f4f6518..00000000 --- a/app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryBoardRendererRequester.java +++ /dev/null @@ -1,80 +0,0 @@ -package app.revanced.integrations.patches.spoof.requests; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import app.revanced.integrations.patches.spoof.StoryboardRenderer; -import app.revanced.integrations.requests.Requester; -import app.revanced.integrations.utils.LogHelper; -import app.revanced.integrations.utils.ReVancedUtils; -import org.json.JSONObject; - -import java.net.HttpURLConnection; -import java.net.SocketTimeoutException; -import java.nio.charset.StandardCharsets; -import java.util.Objects; - -import static app.revanced.integrations.patches.spoof.requests.PlayerRoutes.POST_STORYBOARD_SPEC_RENDERER; - -public class StoryBoardRendererRequester { - private static final String INNER_TUBE_BODY = - "{" + - "\"context\": " + - "{" + - "\"client\": " + - "{ " + - "\"clientName\": \"ANDROID\", \"clientVersion\": \"18.37.36\", \"platform\": \"MOBILE\", " + - "\"osName\": \"Android\", \"osVersion\": \"12\", \"androidSdkVersion\": 31 " + - "} " + - "}, " + - "\"videoId\": \"%s\"" + - "}"; - - private StoryBoardRendererRequester() { - } - - @Nullable - public static StoryboardRenderer fetchStoryboardRenderer(@NonNull String videoId) { - try { - ReVancedUtils.verifyOffMainThread(); - Objects.requireNonNull(videoId); - - final byte[] innerTubeBody = String.format(INNER_TUBE_BODY, videoId).getBytes(StandardCharsets.UTF_8); - - HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(POST_STORYBOARD_SPEC_RENDERER); - connection.getOutputStream().write(innerTubeBody, 0, innerTubeBody.length); - - final int responseCode = connection.getResponseCode(); - - if (responseCode == 200) { - final JSONObject playerResponse = Requester.parseJSONObject(connection); - - if (!playerResponse.has("storyboards")) { - // Video is age restricted or paid. - LogHelper.printDebug(() -> "Video has no public storyboard: " + videoId); - return null; - } - final JSONObject storyboards = playerResponse.getJSONObject("storyboards"); - final String storyboardsRendererTag = storyboards.has("playerLiveStoryboardSpecRenderer") - ? "playerLiveStoryboardSpecRenderer" - : "playerStoryboardSpecRenderer"; - - final var rendererElement = storyboards.getJSONObject(storyboardsRendererTag); - StoryboardRenderer renderer = new StoryboardRenderer( - rendererElement.getString("spec"), - rendererElement.getInt("recommendedLevel") - ); - LogHelper.printDebug(() -> "Fetched: " + renderer); - return renderer; - } else { - LogHelper.printException(() -> "API not available: " + responseCode); - connection.disconnect(); - } - } catch (SocketTimeoutException ex) { - LogHelper.printException(() -> "API timed out", ex); - } catch (Exception ex) { - LogHelper.printException(() -> "Failed to fetch StoryBoard URL", ex); - } - - return null; - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryboardRendererRequester.java b/app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryboardRendererRequester.java new file mode 100644 index 00000000..105772ef --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/patches/spoof/requests/StoryboardRendererRequester.java @@ -0,0 +1,113 @@ +package app.revanced.integrations.patches.spoof.requests; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import app.revanced.integrations.patches.spoof.StoryboardRenderer; +import app.revanced.integrations.requests.Requester; +import app.revanced.integrations.utils.LogHelper; +import app.revanced.integrations.utils.ReVancedUtils; +import org.json.JSONException; +import org.json.JSONObject; + +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import static app.revanced.integrations.patches.spoof.requests.PlayerRoutes.*; + +public class StoryboardRendererRequester { + private StoryboardRendererRequester() { + } + + @Nullable + private static JSONObject fetchPlayerResponse(@NonNull String requestBody) { + try { + ReVancedUtils.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(); + if (responseCode == 200) return Requester.parseJSONObject(connection); + + LogHelper.printException(() -> "API not available: " + responseCode); + connection.disconnect(); + } catch (SocketTimeoutException ex) { + LogHelper.printException(() -> "API timed out", ex); + } catch (Exception ex) { + LogHelper.printException(() -> "Failed to fetch storyboard URL", ex); + } + + return null; + } + + private static boolean isPlayabilityStatusOk(@NonNull JSONObject playerResponse) { + try { + return playerResponse.getJSONObject("playabilityStatus").getString("status").equals("OK"); + } catch (JSONException e) { + LogHelper.printException(() -> "Failed to get playabilityStatus", e); + } + + 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(@NonNull String innerTubeBody) { + final JSONObject playerResponse = fetchPlayerResponse(innerTubeBody); + Objects.requireNonNull(playerResponse); + + if (isPlayabilityStatusOk(playerResponse)) return getStoryboardRendererUsingResponse(playerResponse); + + return null; + } + + @Nullable + private static StoryboardRenderer getStoryboardRendererUsingResponse(@NonNull JSONObject playerResponse) { + try { + final JSONObject storyboards = playerResponse.getJSONObject("storyboards"); + final String storyboardsRendererTag = storyboards.has("playerLiveStoryboardSpecRenderer") + ? "playerLiveStoryboardSpecRenderer" + : "playerStoryboardSpecRenderer"; + + final var rendererElement = storyboards.getJSONObject(storyboardsRendererTag); + StoryboardRenderer renderer = new StoryboardRenderer( + rendererElement.getString("spec"), + rendererElement.getInt("recommendedLevel") + ); + + LogHelper.printDebug(() -> "Fetched: " + renderer); + + return renderer; + } catch (JSONException e) { + LogHelper.printException(() -> "Failed to get storyboardRenderer", e); + } + + return null; + } + + @Nullable + public static StoryboardRenderer getStoryboardRenderer(@NonNull String videoId) { + try { + Objects.requireNonNull(videoId); + + var renderer = getStoryboardRendererUsingBody(String.format(ANDROID_INNER_TUBE_BODY, videoId)); + if (renderer == null) + renderer = getStoryboardRendererUsingBody(String.format(TV_EMBED_INNER_TUBE_BODY, videoId, videoId)); + + return renderer; + } catch (Exception ex) { + LogHelper.printException(() -> "Failed to fetch storyboard URL", ex); + } + + return null; + } +} \ No newline at end of file