diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/client/AppClient.kt b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/client/AppClient.kt index f4728d69b..d741b51c5 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/client/AppClient.kt +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/client/AppClient.kt @@ -4,6 +4,9 @@ import android.os.Build import app.revanced.extension.shared.patches.PatchStatus import app.revanced.extension.shared.settings.BaseSettings +/** + * Used to fetch streaming data. + */ object AppClient { // IOS /** @@ -46,7 +49,7 @@ object AppClient { // IOS UNPLUGGED /** - * Video not playable: Paid / Movie + * Video not playable: Paid / Movie / Playlists / Music * Note: Audio track available */ private const val PACKAGE_NAME_IOS_UNPLUGGED = "com.google.ios.youtubeunplugged" @@ -170,7 +173,6 @@ object AppClient { return BaseSettings.SPOOF_STREAMING_DATA_IOS_FORCE_AVC.get() } - @JvmStatic val availableClientTypes: Array get() = if (PatchStatus.SpoofStreamingDataMusic()) ClientType.CLIENT_ORDER_TO_USE_YOUTUBE_MUSIC @@ -185,43 +187,35 @@ object AppClient { /** * Device model, equivalent to [Build.MODEL] (System property: ro.product.model) */ - @JvmField - val deviceModel: String? = Build.MODEL, + val deviceModel: String = Build.MODEL, /** * Device OS version, equivalent to [Build.VERSION.RELEASE] (System property: ro.system.build.version.release) */ - @JvmField - val osVersion: String? = Build.VERSION.RELEASE, + val osVersion: String = Build.VERSION.RELEASE, /** * Client user-agent. */ - @JvmField val userAgent: String, /** * Android SDK version, equivalent to [Build.VERSION.SDK] (System property: ro.build.version.sdk) * Field is null if not applicable. */ - @JvmField val androidSdkVersion: String? = null, /** * App version. */ - @JvmField val clientVersion: String, /** * If the client can access the API logged in. */ - @JvmField - val canLogin: Boolean? = true, + val canLogin: Boolean = true, /** - * If a poToken should be used. + * Whether a poToken is required to get playback for more than 1 minute. */ - @JvmField - val usePoToken: Boolean? = false, + val requirePoToken: Boolean = false, /** * Friendly name displayed in stats for nerds. */ - @JvmField val friendlyName: String ) { ANDROID_VR( @@ -260,7 +254,7 @@ object AppClient { userAgent = USER_AGENT_IOS, clientVersion = CLIENT_VERSION_IOS, canLogin = false, - usePoToken = true, + requirePoToken = true, friendlyName = if (forceAVC()) "iOS Force AVC" else @@ -274,7 +268,6 @@ object AppClient { friendlyName = "Android Music" ); - @JvmField val clientName: String = name companion object { diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/client/WebClient.kt b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/client/WebClient.kt new file mode 100644 index 000000000..b77806c81 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/client/WebClient.kt @@ -0,0 +1,43 @@ +package app.revanced.extension.shared.patches.client + +/** + * Used to fetch video information. + */ +@Suppress("unused") +object WebClient { + /** + * This user agent does not require a PoToken in [ClientType.MWEB] + * https://github.com/yt-dlp/yt-dlp/blob/0b6b7742c2e7f2a1fcb0b54ef3dd484bab404b3f/yt_dlp/extractor/youtube.py#L259 + */ + private const val USER_AGENT_SAFARI = + "Mozilla/5.0 (iPad; CPU OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1,gzip(gfe)" + + enum class ClientType( + /** + * [YouTube client type](https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients) + */ + val id: Int, + /** + * Client user-agent. + */ + @JvmField + val userAgent: String = USER_AGENT_SAFARI, + /** + * Client version. + */ + @JvmField + val clientVersion: String + ) { + MWEB( + id = 2, + clientVersion = "2.20241202.07.00" + ), + WEB_REMIX( + id = 29, + clientVersion = "1.20241127.01.00", + ); + + @JvmField + val clientName: String = name + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/SpoofStreamingDataPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/SpoofStreamingDataPatch.java index a067331a6..860728123 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/SpoofStreamingDataPatch.java +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/SpoofStreamingDataPatch.java @@ -222,7 +222,6 @@ public class SpoofStreamingDataPatch { final Long approxDurationMs = approxDurationMsMap.get(videoId); if (approxDurationMs != null) { Logger.printDebug(() -> "Replacing video length: " + approxDurationMs + " for videoId: " + videoId); - approxDurationMsMap.remove(videoId); return approxDurationMs; } } diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.java deleted file mode 100644 index 18e01840a..000000000 --- a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.java +++ /dev/null @@ -1,90 +0,0 @@ -package app.revanced.extension.shared.patches.spoof.requests; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.IOException; -import java.net.HttpURLConnection; - -import app.revanced.extension.shared.patches.client.AppClient.ClientType; -import app.revanced.extension.shared.requests.Requester; -import app.revanced.extension.shared.requests.Route; -import app.revanced.extension.shared.utils.Logger; -import app.revanced.extension.shared.utils.Utils; - -@SuppressWarnings({"ExtractMethodRecommender", "deprecation"}) -public final class PlayerRoutes { - public static final Route.CompiledRoute GET_PLAYLIST_PAGE = new Route( - Route.Method.POST, - "next" + - "?fields=contents.singleColumnWatchNextResults.playlist.playlist" - ).compile(); - static final Route.CompiledRoute GET_STREAMING_DATA = new Route( - Route.Method.POST, - "player" + - "?fields=streamingData" + - "&alt=proto" - ).compile(); - private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/"; - /** - * TCP connection and HTTP read timeout - */ - private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds. - - private static final String LOCALE_LANGUAGE = Utils.getContext().getResources() - .getConfiguration().locale.getLanguage(); - - private PlayerRoutes() { - } - - public static JSONObject createInnertubeBody(ClientType clientType) { - JSONObject innerTubeBody = new JSONObject(); - - try { - JSONObject client = new JSONObject(); - client.put("clientName", clientType.clientName); - client.put("clientVersion", clientType.clientVersion); - client.put("deviceModel", clientType.deviceModel); - client.put("osVersion", clientType.osVersion); - if (clientType.androidSdkVersion != null) { - client.put("androidSdkVersion", clientType.androidSdkVersion); - client.put("osName", "Android"); - } else { - client.put("deviceMake", "Apple"); - client.put("osName", "iOS"); - } - if (!clientType.canLogin) { - client.put("hl", LOCALE_LANGUAGE); - } - - JSONObject context = new JSONObject(); - 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); - } - - return innerTubeBody; - } - - /** - * @noinspection SameParameterValue - */ - public static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException { - var connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route); - - connection.setRequestProperty("Content-Type", "application/json"); - connection.setRequestProperty("User-Agent", clientType.userAgent); - - connection.setUseCaches(false); - connection.setDoOutput(true); - - connection.setConnectTimeout(CONNECTION_TIMEOUT_MILLISECONDS); - connection.setReadTimeout(CONNECTION_TIMEOUT_MILLISECONDS); - return connection; - } -} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt new file mode 100644 index 000000000..36bf03e01 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/PlayerRoutes.kt @@ -0,0 +1,147 @@ +package app.revanced.extension.shared.patches.spoof.requests + +import app.revanced.extension.shared.patches.client.AppClient +import app.revanced.extension.shared.patches.client.WebClient +import app.revanced.extension.shared.requests.Requester +import app.revanced.extension.shared.requests.Route +import app.revanced.extension.shared.requests.Route.CompiledRoute +import app.revanced.extension.shared.utils.Logger +import app.revanced.extension.shared.utils.Utils +import org.apache.commons.lang3.StringUtils +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.net.HttpURLConnection +import java.nio.charset.StandardCharsets + +@Suppress("deprecation") +object PlayerRoutes { + @JvmField + val GET_CATEGORY: CompiledRoute = Route( + Route.Method.POST, + "player" + + "?fields=microformat.playerMicroformatRenderer.category" + ).compile() + + @JvmField + val GET_PLAYLIST_PAGE: CompiledRoute = Route( + Route.Method.POST, + "next" + + "?fields=contents.singleColumnWatchNextResults.playlist.playlist" + ).compile() + + @JvmField + val GET_STREAMING_DATA: CompiledRoute = Route( + Route.Method.POST, + "player" + + "?fields=streamingData" + + "&alt=proto" + ).compile() + + private const val YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/" + + /** + * TCP connection and HTTP read timeout + */ + private const val CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000 // 10 Seconds. + + private val LOCALE_LANGUAGE: String = Utils.getContext().resources + .configuration.locale.language + + @JvmStatic + fun createApplicationRequestBody( + clientType: AppClient.ClientType, + videoId: String, + playlistId: String? = null, + botGuardPoToken: String? = null, + visitorId: String? = null, + ): ByteArray { + val innerTubeBody = JSONObject() + + try { + val client = JSONObject() + client.put("clientName", clientType.clientName) + client.put("clientVersion", clientType.clientVersion) + client.put("deviceModel", clientType.deviceModel) + client.put("osVersion", clientType.osVersion) + if (clientType.androidSdkVersion != null) { + client.put("androidSdkVersion", clientType.androidSdkVersion) + client.put("osName", "Android") + } else { + client.put("deviceMake", "Apple") + client.put("osName", "iOS") + } + if (!clientType.canLogin) { + client.put("hl", LOCALE_LANGUAGE) + } + + val context = JSONObject() + context.put("client", client) + + innerTubeBody.put("context", context) + innerTubeBody.put("contentCheckOk", true) + innerTubeBody.put("racyCheckOk", true) + innerTubeBody.put("videoId", videoId) + + if (playlistId != null) { + innerTubeBody.put("playlistId", playlistId) + } + + if (!StringUtils.isAnyEmpty(botGuardPoToken, visitorId)) { + val serviceIntegrityDimensions = JSONObject() + serviceIntegrityDimensions.put("poToken", botGuardPoToken) + innerTubeBody.put("serviceIntegrityDimensions", serviceIntegrityDimensions) + } + } catch (e: JSONException) { + Logger.printException({ "Failed to create application innerTubeBody" }, e) + } + + return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8) + } + + @JvmStatic + fun createWebInnertubeBody( + clientType: WebClient.ClientType, + videoId: String + ): ByteArray { + val innerTubeBody = JSONObject() + + try { + val client = JSONObject() + client.put("clientName", clientType.clientName) + client.put("clientVersion", clientType.clientVersion) + val context = JSONObject() + context.put("client", client) + + val lockedSafetyMode = JSONObject() + lockedSafetyMode.put("lockedSafetyMode", false) + val user = JSONObject() + user.put("user", lockedSafetyMode) + + innerTubeBody.put("context", context) + innerTubeBody.put("contentCheckOk", true) + innerTubeBody.put("racyCheckOk", true) + innerTubeBody.put("videoId", videoId) + } catch (e: JSONException) { + Logger.printException({ "Failed to create web innerTubeBody" }, e) + } + + return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8) + } + + @JvmStatic + @Throws(IOException::class) + fun getPlayerResponseConnectionFromRoute(route: CompiledRoute, userAgent: String): HttpURLConnection { + val connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route) + + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("User-Agent", userAgent) + + connection.useCaches = false + connection.doOutput = true + + connection.connectTimeout = CONNECTION_TIMEOUT_MILLISECONDS + connection.readTimeout = CONNECTION_TIMEOUT_MILLISECONDS + return connection + } +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/StreamingDataRequest.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/StreamingDataRequest.java deleted file mode 100644 index b4da8c61c..000000000 --- a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/StreamingDataRequest.java +++ /dev/null @@ -1,267 +0,0 @@ -package app.revanced.extension.shared.patches.spoof.requests; - -import static app.revanced.extension.shared.patches.client.AppClient.getAvailableClientTypes; -import static app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.apache.commons.lang3.ArrayUtils; -import org.json.JSONObject; - -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.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import app.revanced.extension.shared.patches.client.AppClient.ClientType; -import app.revanced.extension.shared.settings.BaseSettings; -import app.revanced.extension.shared.utils.Logger; -import app.revanced.extension.shared.utils.Utils; - -/** - * 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 extension replace stream hook is called only if YT - * did use its own client streams. - */ -public class StreamingDataRequest { - - private static final ClientType[] CLIENT_ORDER_TO_USE; - private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final String VISITOR_ID_HEADER = "X-Goog-Visitor-Id"; - private static final String[] REQUEST_HEADER_KEYS = { - AUTHORIZATION_HEADER, // Available only to logged-in users. - "X-GOOG-API-FORMAT-VERSION", - VISITOR_ID_HEADER - }; - private static ClientType lastSpoofedClientType; - - - /** - * 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 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 String getLastSpoofedClientName() { - return lastSpoofedClientType == null - ? "Unknown" - : lastSpoofedClientType.friendlyName; - } - - static { - ClientType[] allClientTypes = getAvailableClientTypes(); - ClientType preferredClient = BaseSettings.SPOOF_STREAMING_DATA_TYPE.get(); - - if (ArrayUtils.indexOf(allClientTypes, preferredClient) < 0) { - CLIENT_ORDER_TO_USE = allClientTypes; - } else { - 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; - } - } - } - } - - private final String videoId; - private final Future future; - - private StreamingDataRequest(String videoId, Map playerHeaders, String visitorId, - String botGuardPoToken, String droidGuardPoToken) { - Objects.requireNonNull(playerHeaders); - this.videoId = videoId; - this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders, visitorId, botGuardPoToken, droidGuardPoToken)); - } - - public static void fetchRequest(String videoId, Map fetchHeaders, String visitorId, - String botGuardPoToken, String droidGuardPoToken) { - // Always fetch, even if there is an existing request for the same video. - cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders, visitorId, botGuardPoToken, droidGuardPoToken)); - } - - @Nullable - public static StreamingDataRequest getRequestForVideoId(String videoId) { - return cache.get(videoId); - } - - private static void handleConnectionError(String toastMessage, @Nullable Exception ex) { - Logger.printInfo(() -> toastMessage, ex); - } - - @Nullable - private static HttpURLConnection send(ClientType clientType, String videoId, Map playerHeaders, - String visitorId, String botGuardPoToken, String droidGuardPoToken) { - Objects.requireNonNull(clientType); - Objects.requireNonNull(videoId); - Objects.requireNonNull(playerHeaders); - - final long startTime = System.currentTimeMillis(); - Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType); - - try { - HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType); - connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS); - connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS); - - for (String key : REQUEST_HEADER_KEYS) { - String value = playerHeaders.get(key); - if (value != null) { - if (key.equals(AUTHORIZATION_HEADER)) { - if (!clientType.canLogin) { - Logger.printDebug(() -> "Not including request header: " + key); - continue; - } - } - if (key.equals(VISITOR_ID_HEADER) && - clientType.usePoToken && - !botGuardPoToken.isEmpty() && - !visitorId.isEmpty()) { - String originalVisitorId = value; - Logger.printDebug(() -> "Original visitor id:\n" + originalVisitorId); - Logger.printDebug(() -> "Replaced visitor id:\n" + visitorId); - value = visitorId; - } - - connection.setRequestProperty(key, value); - } - } - - JSONObject innerTubeBodyJson = PlayerRoutes.createInnertubeBody(clientType); - if (clientType.usePoToken && !botGuardPoToken.isEmpty() && !visitorId.isEmpty()) { - JSONObject serviceIntegrityDimensions = new JSONObject(); - serviceIntegrityDimensions.put("poToken", botGuardPoToken); - innerTubeBodyJson.put("serviceIntegrityDimensions", serviceIntegrityDimensions); - if (!droidGuardPoToken.isEmpty()) { - Logger.printDebug(() -> "Original poToken (droidGuardPoToken):\n" + droidGuardPoToken); - } - Logger.printDebug(() -> "Replaced poToken (botGuardPoToken):\n" + botGuardPoToken); - } - - String innerTubeBody = String.format(innerTubeBodyJson.toString(), 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; - - // This situation likely means the patches are outdated. - // Use a toast message that suggests updating. - handleConnectionError("Playback error (App is outdated?) " + clientType + ": " - + responseCode + " response: " + connection.getResponseMessage(), - null); - } catch (SocketTimeoutException ex) { - handleConnectionError("Connection timeout", ex); - } catch (IOException ex) { - handleConnectionError("Network error", ex); - } 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 playerHeaders, String visitorId, - String botGuardPoToken, String droidGuardPoToken) { - lastSpoofedClientType = null; - - // Retry with different client if empty response body is received. - for (ClientType clientType : CLIENT_ORDER_TO_USE) { - HttpURLConnection connection = send(clientType, videoId, playerHeaders, visitorId, botGuardPoToken, droidGuardPoToken); - if (connection != null) { - try { - // gzip encoding doesn't response with content length (-1), - // but empty response body does. - if (connection.getContentLength() == 0) { - Logger.printDebug(() -> "Received empty response" + "\nClient: " + clientType + "\nVideo: " + videoId); - } else { - try (InputStream inputStream = new BufferedInputStream(connection.getInputStream()); - ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - byte[] buffer = new byte[2048]; - int bytesRead; - while ((bytesRead = inputStream.read(buffer)) >= 0) { - baos.write(buffer, 0, bytesRead); - } - lastSpoofedClientType = clientType; - - 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); - return null; - } - - 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 + '\'' + '}'; - } -} diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/StreamingDataRequest.kt b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/StreamingDataRequest.kt new file mode 100644 index 000000000..1d6837b9f --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spoof/requests/StreamingDataRequest.kt @@ -0,0 +1,299 @@ +package app.revanced.extension.shared.patches.spoof.requests + +import androidx.annotation.GuardedBy +import app.revanced.extension.shared.patches.client.AppClient +import app.revanced.extension.shared.patches.client.AppClient.availableClientTypes +import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA +import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.createApplicationRequestBody +import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.getPlayerResponseConnectionFromRoute +import app.revanced.extension.shared.settings.BaseSettings +import app.revanced.extension.shared.utils.Logger +import app.revanced.extension.shared.utils.Utils +import org.apache.commons.lang3.ArrayUtils +import org.apache.commons.lang3.StringUtils +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.SocketTimeoutException +import java.nio.ByteBuffer +import java.util.Collections +import java.util.Objects +import java.util.concurrent.ExecutionException +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * 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 extension replace stream hook is called only if YT + * did use its own client streams. + */ +class StreamingDataRequest private constructor( + videoId: String, playerHeaders: Map, visitorId: String, + botGuardPoToken: String, droidGuardPoToken: String +) { + private val videoId: String + private val future: Future + + init { + Objects.requireNonNull(playerHeaders) + this.videoId = videoId + this.future = Utils.submitOnBackgroundThread { + fetch( + videoId, + playerHeaders, + visitorId, + botGuardPoToken, + droidGuardPoToken + ) + } + } + + fun fetchCompleted(): Boolean { + return future.isDone + } + + val stream: ByteBuffer? + get() { + try { + return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH.toLong(), TimeUnit.MILLISECONDS] + } catch (ex: TimeoutException) { + Logger.printInfo( + { "getStream timed out" }, + ex + ) + } catch (ex: InterruptedException) { + Logger.printException( + { "getStream interrupted" }, + ex + ) + Thread.currentThread().interrupt() // Restore interrupt status flag. + } catch (ex: ExecutionException) { + Logger.printException( + { "getStream failure" }, + ex + ) + } + + return null + } + + override fun toString(): String { + return "StreamingDataRequest{videoId='$videoId'}" + } + + companion object { + private var CLIENT_ORDER_TO_USE: Array + private const val AUTHORIZATION_HEADER = "Authorization" + private const val VISITOR_ID_HEADER = "X-Goog-Visitor-Id" + private val REQUEST_HEADER_KEYS = arrayOf( + AUTHORIZATION_HEADER, // Available only to logged-in users. + "X-GOOG-API-FORMAT-VERSION", + VISITOR_ID_HEADER + ) + private var lastSpoofedClientType: AppClient.ClientType? = null + + + /** + * TCP connection and HTTP read timeout. + */ + private const val HTTP_TIMEOUT_MILLISECONDS = 10 * 1000 + + /** + * Any arbitrarily large value, but must be at least twice [.HTTP_TIMEOUT_MILLISECONDS] + */ + private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000 + @GuardedBy("itself") + val cache: MutableMap = Collections.synchronizedMap( + object : 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 val CACHE_LIMIT = 50 + + override fun removeEldestEntry(eldest: Map.Entry): Boolean { + return size > CACHE_LIMIT // Evict the oldest entry if over the cache limit. + } + }) + + @JvmStatic + val lastSpoofedClientName: String + get() = lastSpoofedClientType + ?.friendlyName + ?: "Unknown" + + init { + val allClientTypes: Array = availableClientTypes + val preferredClient = BaseSettings.SPOOF_STREAMING_DATA_TYPE.get() + + CLIENT_ORDER_TO_USE = allClientTypes + if (ArrayUtils.indexOf(allClientTypes, preferredClient) >= 0) { + CLIENT_ORDER_TO_USE[0] = preferredClient + var i = 1 + for (c in allClientTypes) { + if (c != preferredClient) { + CLIENT_ORDER_TO_USE[i++] = c + } + } + } + } + + @JvmStatic + fun fetchRequest( + videoId: String, fetchHeaders: Map, visitorId: String, + botGuardPoToken: String, droidGuardPoToken: String + ) { + // Always fetch, even if there is an existing request for the same video. + cache[videoId] = + StreamingDataRequest( + videoId, + fetchHeaders, + visitorId, + botGuardPoToken, + droidGuardPoToken + ) + } + + @JvmStatic + fun getRequestForVideoId(videoId: String): StreamingDataRequest? { + return cache[videoId] + } + + private fun handleConnectionError(toastMessage: String, ex: Exception?) { + Logger.printInfo({ toastMessage }, ex) + } + + private fun send( + clientType: AppClient.ClientType, videoId: String, playerHeaders: Map, + visitorId: String, botGuardPoToken: String, droidGuardPoToken: String + ): HttpURLConnection? { + Objects.requireNonNull(clientType) + Objects.requireNonNull(videoId) + Objects.requireNonNull(playerHeaders) + + val startTime = System.currentTimeMillis() + Logger.printDebug { "Fetching video streams for: $videoId using client: $clientType" } + + try { + val connection = getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType.userAgent) + connection.connectTimeout = HTTP_TIMEOUT_MILLISECONDS + connection.readTimeout = HTTP_TIMEOUT_MILLISECONDS + + val usePoToken = clientType.requirePoToken && !StringUtils.isAnyEmpty(botGuardPoToken, visitorId) + + for (key in REQUEST_HEADER_KEYS) { + var value = playerHeaders[key] + if (value != null) { + if (key == AUTHORIZATION_HEADER) { + if (!clientType.canLogin) { + Logger.printDebug { "Not including request header: $key" } + continue + } + } + if (key == VISITOR_ID_HEADER && usePoToken) { + val originalVisitorId: String = value + Logger.printDebug { "Original visitor id:\n$originalVisitorId" } + Logger.printDebug { "Replaced visitor id:\n$visitorId" } + value = visitorId + } + + connection.setRequestProperty(key, value) + } + } + + val requestBody: ByteArray + if (usePoToken) { + requestBody = createApplicationRequestBody( + clientType = clientType, + videoId = videoId, + botGuardPoToken = botGuardPoToken, + visitorId = visitorId + ) + if (droidGuardPoToken.isNotEmpty()) { + Logger.printDebug { "Original poToken (droidGuardPoToken):\n$droidGuardPoToken" } + } + Logger.printDebug { "Replaced poToken (botGuardPoToken):\n$botGuardPoToken" } + } else { + requestBody = + createApplicationRequestBody(clientType = clientType, videoId = videoId) + } + connection.setFixedLengthStreamingMode(requestBody.size) + connection.outputStream.write(requestBody) + + val responseCode = connection.responseCode + if (responseCode == 200) return connection + + // This situation likely means the patches are outdated. + // Use a toast message that suggests updating. + handleConnectionError( + ("Playback error (App is outdated?) " + clientType + ": " + + responseCode + " response: " + connection.responseMessage), + null + ) + } catch (ex: SocketTimeoutException) { + handleConnectionError("Connection timeout", ex) + } catch (ex: IOException) { + handleConnectionError("Network error", ex) + } catch (ex: Exception) { + Logger.printException({ "send failed" }, ex) + } finally { + Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" } + } + + return null + } + + private fun fetch( + videoId: String, playerHeaders: Map, visitorId: String, + botGuardPoToken: String, droidGuardPoToken: String + ): ByteBuffer? { + lastSpoofedClientType = null + + // Retry with different client if empty response body is received. + for (clientType in CLIENT_ORDER_TO_USE) { + send( + clientType, + videoId, + playerHeaders, + visitorId, + botGuardPoToken, + droidGuardPoToken + )?.let { connection -> + try { + // gzip encoding doesn't response with content length (-1), + // but empty response body does. + if (connection.contentLength == 0) { + Logger.printDebug { "Received empty response\nClient: $clientType\nVideo: $videoId" } + } else { + BufferedInputStream(connection.inputStream).use { inputStream -> + ByteArrayOutputStream().use { stream -> + val buffer = ByteArray(2048) + var bytesRead: Int + while ((inputStream.read(buffer).also { bytesRead = it }) >= 0) { + stream.write(buffer, 0, bytesRead) + } + lastSpoofedClientType = clientType + return ByteBuffer.wrap(stream.toByteArray()) + } + } + } + } catch (ex: IOException) { + Logger.printException({ "Fetch failed while processing response data" }, ex) + } + } + } + + handleConnectionError("Could not fetch any client streams", null) + return null + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/PlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/PlaybackSpeedPatch.java index ca2ad8fc1..5211c6a47 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/PlaybackSpeedPatch.java +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/PlaybackSpeedPatch.java @@ -10,13 +10,15 @@ import org.apache.commons.lang3.BooleanUtils; import app.revanced.extension.shared.utils.Logger; import app.revanced.extension.shared.utils.Utils; import app.revanced.extension.youtube.patches.utils.PatchStatus; -import app.revanced.extension.youtube.patches.video.requests.PlaylistRequest; +import app.revanced.extension.youtube.patches.video.requests.MusicRequest; import app.revanced.extension.youtube.settings.Settings; import app.revanced.extension.youtube.shared.VideoInformation; import app.revanced.extension.youtube.whitelist.Whitelist; @SuppressWarnings("unused") public class PlaybackSpeedPatch { + private static final boolean DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC = + Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get(); private static final long TOAST_DELAY_MILLISECONDS = 750; private static long lastTimeSpeedChanged; private static boolean isLiveStream; @@ -39,8 +41,8 @@ public class PlaybackSpeedPatch { /** * Injection point. */ - public static void fetchPlaylistData(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) { - if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get()) { + public static void fetchMusicRequest(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) { + if (DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC) { try { final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort(); // Shorts shelf in home and subscription feed causes player response hook to be called, @@ -50,9 +52,12 @@ public class PlaybackSpeedPatch { return; } - PlaylistRequest.fetchRequestIfNeeded(videoId); + MusicRequest.fetchRequestIfNeeded( + videoId, + Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC_TYPE.get() + ); } catch (Exception ex) { - Logger.printException(() -> "fetchPlaylistData failure", ex); + Logger.printException(() -> "fetchMusicRequest failure", ex); } } } @@ -61,15 +66,16 @@ public class PlaybackSpeedPatch { * Injection point. */ public static float getPlaybackSpeedInShorts(final float playbackSpeed) { - if (!VideoInformation.lastPlayerResponseIsShort()) - return playbackSpeed; - if (!Settings.ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS.get()) - return playbackSpeed; + if (VideoInformation.lastPlayerResponseIsShort() && + Settings.ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS.get() + ) { + float defaultPlaybackSpeed = getDefaultPlaybackSpeed(VideoInformation.getChannelId(), null); + Logger.printDebug(() -> "overridePlaybackSpeed in Shorts: " + defaultPlaybackSpeed); - float defaultPlaybackSpeed = getDefaultPlaybackSpeed(VideoInformation.getChannelId(), null); - Logger.printDebug(() -> "overridePlaybackSpeed in Shorts: " + defaultPlaybackSpeed); + return defaultPlaybackSpeed; + } - return defaultPlaybackSpeed; + return playbackSpeed; } /** @@ -118,23 +124,21 @@ public class PlaybackSpeedPatch { } private static float getDefaultPlaybackSpeed(@NonNull String channelId, @Nullable String videoId) { - return (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_LIVE.get() && isLiveStream) || - Whitelist.isChannelWhitelistedPlaybackSpeed(channelId) || - getPlaylistData(videoId) + return (isLiveStream || Whitelist.isChannelWhitelistedPlaybackSpeed(channelId) || isMusic(videoId)) ? 1.0f : Settings.DEFAULT_PLAYBACK_SPEED.get(); } - private static boolean getPlaylistData(@Nullable String videoId) { - if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get() && videoId != null) { + private static boolean isMusic(@Nullable String videoId) { + if (DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC && videoId != null) { try { - PlaylistRequest request = PlaylistRequest.getRequestForVideoId(videoId); - final boolean isPlaylist = request != null && BooleanUtils.toBoolean(request.getStream()); - Logger.printDebug(() -> "isPlaylist: " + isPlaylist); + MusicRequest request = MusicRequest.getRequestForVideoId(videoId); + final boolean isMusic = request != null && BooleanUtils.toBoolean(request.getStream()); + Logger.printDebug(() -> "videoId: " + videoId + ", isMusic: " + isMusic); - return isPlaylist; + return isMusic; } catch (Exception ex) { - Logger.printException(() -> "getPlaylistData failure", ex); + Logger.printException(() -> "getMusicRequest failure", ex); } } diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/requests/MusicRequest.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/requests/MusicRequest.kt new file mode 100644 index 000000000..72d765c16 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/requests/MusicRequest.kt @@ -0,0 +1,240 @@ +package app.revanced.extension.youtube.patches.video.requests + +import android.annotation.SuppressLint +import androidx.annotation.GuardedBy +import app.revanced.extension.shared.patches.client.AppClient +import app.revanced.extension.shared.patches.client.WebClient +import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes +import app.revanced.extension.shared.requests.Requester +import app.revanced.extension.shared.utils.Logger +import app.revanced.extension.shared.utils.Utils +import app.revanced.extension.youtube.shared.VideoInformation +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.net.SocketTimeoutException +import java.util.Objects +import java.util.concurrent.ExecutionException +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +class MusicRequest private constructor(private val videoId: String, private val checkCategory: Boolean) { + /** + * Time this instance and the fetch future was created. + */ + private val timeFetched = System.currentTimeMillis() + private val future: Future = Utils.submitOnBackgroundThread { + fetch( + videoId, + checkCategory, + ) + } + + fun isExpired(now: Long): Boolean { + val timeSinceCreation = now - timeFetched + if (timeSinceCreation > CACHE_RETENTION_TIME_MILLISECONDS) { + return true + } + + // Only expired if the fetch failed (API null response). + return (fetchCompleted() && stream == null) + } + + /** + * @return if the fetch call has completed. + */ + private fun fetchCompleted(): Boolean { + return future.isDone + } + + val stream: Boolean? + get() { + try { + return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS] + } catch (ex: TimeoutException) { + Logger.printInfo( + { "getStream timed out" }, + ex + ) + } catch (ex: InterruptedException) { + Logger.printException( + { "getStream interrupted" }, + ex + ) + Thread.currentThread().interrupt() // Restore interrupt status flag. + } catch (ex: ExecutionException) { + Logger.printException( + { "getStream failure" }, + ex + ) + } + + return null + } + + companion object { + /** + * How long to keep fetches until they are expired. + */ + private const val CACHE_RETENTION_TIME_MILLISECONDS = 60 * 1000L // 1 Minute + + private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000L // 20 seconds + + @GuardedBy("itself") + private val cache: MutableMap = HashMap() + + @JvmStatic + @SuppressLint("ObsoleteSdkInt") + fun fetchRequestIfNeeded(videoId: String, checkCategory: Boolean) { + Objects.requireNonNull(videoId) + synchronized(cache) { + val now = System.currentTimeMillis() + cache.values.removeIf { request: MusicRequest -> + val expired = request.isExpired(now) + if (expired) Logger.printDebug { "Removing expired stream: " + request.videoId } + expired + } + if (!cache.containsKey(videoId)) { + cache[videoId] = MusicRequest(videoId, checkCategory) + } + } + } + + @JvmStatic + fun getRequestForVideoId(videoId: String): MusicRequest? { + synchronized(cache) { + return cache[videoId] + } + } + + private fun handleConnectionError(toastMessage: String, ex: Exception?) { + Logger.printInfo({ toastMessage }, ex) + } + + private fun sendApplicationRequest(videoId: String): JSONObject? { + Objects.requireNonNull(videoId) + + val startTime = System.currentTimeMillis() + val clientType = AppClient.ClientType.ANDROID_VR + val clientTypeName = clientType.name + Logger.printDebug { "Fetching playlist request for: $videoId using client: $clientTypeName" } + + try { + val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(PlayerRoutes.GET_PLAYLIST_PAGE, clientType.userAgent) + val requestBody = + PlayerRoutes.createApplicationRequestBody(clientType, videoId, "RD$videoId") + + connection.setFixedLengthStreamingMode(requestBody.size) + connection.outputStream.write(requestBody) + + val responseCode = connection.responseCode + if (responseCode == 200) return Requester.parseJSONObject(connection) + + handleConnectionError( + (clientTypeName + " not available with response code: " + + responseCode + " message: " + connection.responseMessage), + null + ) + } catch (ex: SocketTimeoutException) { + handleConnectionError("Connection timeout", ex) + } catch (ex: IOException) { + handleConnectionError("Network error", ex) + } catch (ex: Exception) { + Logger.printException({ "sendApplicationRequest failed" }, ex) + } finally { + Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" } + } + + return null + } + + private fun sendWebRequest(videoId: String): JSONObject? { + Objects.requireNonNull(videoId) + + val startTime = System.currentTimeMillis() + val clientType = WebClient.ClientType.MWEB + val clientTypeName = clientType.name + Logger.printDebug { "Fetching playability request for: $videoId using client: $clientTypeName" } + + try { + val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(PlayerRoutes.GET_CATEGORY, clientType.userAgent) + val requestBody = + PlayerRoutes.createWebInnertubeBody(clientType, videoId) + + connection.setFixedLengthStreamingMode(requestBody.size) + connection.outputStream.write(requestBody) + + val responseCode = connection.responseCode + if (responseCode == 200) return Requester.parseJSONObject(connection) + + handleConnectionError( + (clientTypeName + " not available with response code: " + + responseCode + " message: " + connection.responseMessage), + null + ) + } catch (ex: SocketTimeoutException) { + handleConnectionError("Connection timeout", ex) + } catch (ex: IOException) { + handleConnectionError("Network error", ex) + } catch (ex: Exception) { + Logger.printException({ "sendWebRequest failed" }, ex) + } finally { + Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" } + } + + return null + } + + private fun parseApplicationResponse(playlistJson: JSONObject): Boolean { + try { + val playerParams: String? = (playlistJson + .getJSONObject("contents") + .getJSONObject("singleColumnWatchNextResults") + .getJSONObject("playlist") + .getJSONObject("playlist") + .getJSONArray("contents")[0] as JSONObject) + .getJSONObject("playlistPanelVideoRenderer") + .getJSONObject("navigationEndpoint") + .getJSONObject("watchEndpoint") + .getString("playerParams") + + return VideoInformation.isMixPlaylistsOpenedByUser(playerParams!!) + } catch (e: JSONException) { + Logger.printDebug { "Fetch failed while processing Application response data for response: $playlistJson" } + } + + return false + } + + private fun parseWebResponse(microFormatJson: JSONObject): Boolean { + try { + return microFormatJson + .getJSONObject("playerMicroformatRenderer") + .getJSONObject("category") + .getString("status") + .equals("Music") + } catch (e: JSONException) { + Logger.printDebug { "Fetch failed while processing Web response data for response: $microFormatJson" } + } + + return false + } + + private fun fetch(videoId: String, checkCategory: Boolean): Boolean { + if (checkCategory) { + val microFormatJson = sendWebRequest(videoId) + if (microFormatJson != null) { + return parseWebResponse(microFormatJson) + } + } else { + val playlistJson = sendApplicationRequest(videoId) + if (playlistJson != null) { + return parseApplicationResponse(playlistJson) + } + } + + return false + } + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/requests/PlaylistRequest.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/requests/PlaylistRequest.java deleted file mode 100644 index db12d6d23..000000000 --- a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/requests/PlaylistRequest.java +++ /dev/null @@ -1,205 +0,0 @@ -package app.revanced.extension.youtube.patches.video.requests; - -import static app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.GET_PLAYLIST_PAGE; - -import android.annotation.SuppressLint; - -import androidx.annotation.GuardedBy; -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.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import app.revanced.extension.shared.patches.client.AppClient.ClientType; -import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes; -import app.revanced.extension.shared.requests.Requester; -import app.revanced.extension.shared.utils.Logger; -import app.revanced.extension.shared.utils.Utils; -import app.revanced.extension.youtube.shared.VideoInformation; - -public class PlaylistRequest { - - /** - * How long to keep fetches until they are expired. - */ - private static final long CACHE_RETENTION_TIME_MILLISECONDS = 60 * 1000; // 1 Minute - - private static final long MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000; // 20 seconds - - @GuardedBy("itself") - private static final Map cache = new HashMap<>(); - - @SuppressLint("ObsoleteSdkInt") - public static void fetchRequestIfNeeded(@Nullable String videoId) { - Objects.requireNonNull(videoId); - synchronized (cache) { - final long now = System.currentTimeMillis(); - - cache.values().removeIf(request -> { - final boolean expired = request.isExpired(now); - if (expired) Logger.printDebug(() -> "Removing expired stream: " + request.videoId); - return expired; - }); - - if (!cache.containsKey(videoId)) { - cache.put(videoId, new PlaylistRequest(videoId)); - } - } - } - - @Nullable - public static PlaylistRequest getRequestForVideoId(@Nullable String videoId) { - synchronized (cache) { - return cache.get(videoId); - } - } - - private static void handleConnectionError(String toastMessage, @Nullable Exception ex) { - Logger.printInfo(() -> toastMessage, ex); - } - - @Nullable - private static JSONObject send(ClientType clientType, String videoId) { - Objects.requireNonNull(clientType); - Objects.requireNonNull(videoId); - - final long startTime = System.currentTimeMillis(); - String clientTypeName = clientType.name(); - Logger.printDebug(() -> "Fetching playlist request for: " + videoId + " using client: " + clientTypeName); - - try { - HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_PLAYLIST_PAGE, clientType); - - JSONObject innerTubeBodyJson = PlayerRoutes.createInnertubeBody(clientType); - innerTubeBodyJson.put("playlistId", "%s"); - - String innerTubeBody = String.format( - Locale.ENGLISH, - innerTubeBodyJson.toString(), - videoId, - "RD" + 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 Requester.parseJSONObject(connection); - - handleConnectionError(clientTypeName + " not available with response code: " - + responseCode + " message: " + connection.getResponseMessage(), - null); - } catch (SocketTimeoutException ex) { - handleConnectionError("Connection timeout", ex); - } catch (IOException ex) { - handleConnectionError("Network error", ex); - } catch (Exception ex) { - Logger.printException(() -> "send failed", ex); - } finally { - Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms"); - } - - return null; - } - - private static Boolean fetch(@NonNull String videoId) { - final ClientType clientType = ClientType.ANDROID_VR; - final JSONObject playlistJson = send(clientType, videoId); - if (playlistJson != null) { - try { - final JSONObject singleColumnWatchNextResultsJsonObject = playlistJson - .getJSONObject("contents") - .getJSONObject("singleColumnWatchNextResults"); - - if (!singleColumnWatchNextResultsJsonObject.has("playlist")) { - return false; - } - - final JSONObject playlistJsonObject = singleColumnWatchNextResultsJsonObject - .getJSONObject("playlist") - .getJSONObject("playlist"); - - final Object currentStreamObject = playlistJsonObject - .getJSONArray("contents") - .get(0); - - if (!(currentStreamObject instanceof JSONObject currentStreamJsonObject)) { - return false; - } - - final JSONObject watchEndpointJsonObject = currentStreamJsonObject - .getJSONObject("playlistPanelVideoRenderer") - .getJSONObject("navigationEndpoint") - .getJSONObject("watchEndpoint"); - - Logger.printDebug(() -> "watchEndpoint: " + watchEndpointJsonObject); - - return watchEndpointJsonObject.has("playerParams") && - VideoInformation.isMixPlaylistsOpenedByUser(watchEndpointJsonObject.getString("playerParams")); - } catch (JSONException e) { - Logger.printDebug(() -> "Fetch failed while processing response data for response: " + playlistJson); - } - } - - return false; - } - - /** - * Time this instance and the fetch future was created. - */ - private final long timeFetched; - private final String videoId; - private final Future future; - - private PlaylistRequest(String videoId) { - this.timeFetched = System.currentTimeMillis(); - this.videoId = videoId; - this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId)); - } - - public boolean isExpired(long now) { - final long timeSinceCreation = now - timeFetched; - if (timeSinceCreation > CACHE_RETENTION_TIME_MILLISECONDS) { - return true; - } - - // Only expired if the fetch failed (API null response). - return (fetchCompleted() && getStream() == null); - } - - /** - * @return if the fetch call has completed. - */ - public boolean fetchCompleted() { - return future.isDone(); - } - - public Boolean 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; - } -} diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java index a7b48bbf5..5093be2df 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java +++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java @@ -524,7 +524,6 @@ public class Settings extends BaseSettings { public static final IntegerSetting DEFAULT_VIDEO_QUALITY_MOBILE = new IntegerSetting("revanced_default_video_quality_mobile", -2); public static final IntegerSetting DEFAULT_VIDEO_QUALITY_WIFI = new IntegerSetting("revanced_default_video_quality_wifi", -2); public static final BooleanSetting DISABLE_HDR_VIDEO = new BooleanSetting("revanced_disable_hdr_video", FALSE, true); - public static final BooleanSetting DISABLE_DEFAULT_PLAYBACK_SPEED_LIVE = new BooleanSetting("revanced_disable_default_playback_speed_live", TRUE); public static final BooleanSetting ENABLE_CUSTOM_PLAYBACK_SPEED = new BooleanSetting("revanced_enable_custom_playback_speed", FALSE, true); public static final BooleanSetting CUSTOM_PLAYBACK_SPEED_MENU_TYPE = new BooleanSetting("revanced_custom_playback_speed_menu_type", FALSE, parent(ENABLE_CUSTOM_PLAYBACK_SPEED)); public static final StringSetting CUSTOM_PLAYBACK_SPEEDS = new StringSetting("revanced_custom_playback_speeds", "0.25\n0.5\n0.75\n1.0\n1.25\n1.5\n1.75\n2.0\n2.25\n2.5", true, parent(ENABLE_CUSTOM_PLAYBACK_SPEED)); @@ -535,6 +534,7 @@ public class Settings extends BaseSettings { public static final BooleanSetting RESTORE_OLD_VIDEO_QUALITY_MENU = new BooleanSetting("revanced_restore_old_video_quality_menu", TRUE, true); // Experimental Flags public static final BooleanSetting DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC = new BooleanSetting("revanced_disable_default_playback_speed_music", FALSE, true); + public static final BooleanSetting DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC_TYPE = new BooleanSetting("revanced_disable_default_playback_speed_music_type", FALSE, true, parent(DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC)); public static final BooleanSetting ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS = new BooleanSetting("revanced_enable_default_playback_speed_shorts", FALSE); public static final BooleanSetting SKIP_PRELOADED_BUFFER = new BooleanSetting("revanced_skip_preloaded_buffer", FALSE, true, "revanced_skip_preloaded_buffer_user_dialog_message"); public static final BooleanSetting SKIP_PRELOADED_BUFFER_TOAST = new BooleanSetting("revanced_skip_preloaded_buffer_toast", TRUE); diff --git a/patches/src/main/kotlin/app/revanced/patches/youtube/video/playback/VideoPlaybackPatch.kt b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playback/VideoPlaybackPatch.kt index ccebc248a..895d7b505 100644 --- a/patches/src/main/kotlin/app/revanced/patches/youtube/video/playback/VideoPlaybackPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/youtube/video/playback/VideoPlaybackPatch.kt @@ -173,7 +173,7 @@ val videoPlaybackPatch = bytecodePatch( } hookBackgroundPlayVideoInformation("$EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V") - hookPlayerResponseVideoId("$EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->fetchPlaylistData(Ljava/lang/String;Z)V") + hookPlayerResponseVideoId("$EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->fetchMusicRequest(Ljava/lang/String;Z)V") updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "RememberPlaybackSpeed") diff --git a/patches/src/main/resources/youtube/settings/host/values/strings.xml b/patches/src/main/resources/youtube/settings/host/values/strings.xml index 03c8fbcfc..8ce18e22a 100644 --- a/patches/src/main/resources/youtube/settings/host/values/strings.xml +++ b/patches/src/main/resources/youtube/settings/host/values/strings.xml @@ -1497,9 +1497,6 @@ No margins on top and bottom of player." Disable HDR video HDR video is disabled. HDR video is enabled. - Disable playback speed for live streams - Default playback speed is disabled for live streams. - Default playback speed is enabled for live streams. Enable custom playback speed Custom playback speed is enabled. Custom playback speed is disabled. @@ -1524,10 +1521,11 @@ No margins on top and bottom of player." Old video quality menu is shown. Old video quality menu is not shown. Disable playback speed for music - "Default playback speed is disabled for music. - -Limitation: This setting may not apply to videos that do not include the 'Listen on YouTube Music' banner." + Default playback speed is disabled for music. Default playback speed is enabled for music. + Validate using categories + Default playback speed is disabled if the video category is Music. + Default playback speed is disabled for videos playable on YouTube Music. Enable Shorts default playback speed Default playback speed applies to Shorts. Default playback speed does not apply to Shorts. diff --git a/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml index e579f0dac..8c4f1daf1 100644 --- a/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml +++ b/patches/src/main/resources/youtube/settings/xml/revanced_prefs.xml @@ -684,7 +684,6 @@ - @@ -695,6 +694,7 @@ +