feat(YouTube - Video playback): Improve Disable music playback speed setting (#117)

* fix(Default Music playback speed): Fix side effect left

* fix(Spoof Streaming Data): `approxDurationMs` can be fetched multiple time

* fix(Spoof Streaming Data): Prevent playback issues on Kids videos with music

* Up to now, there have been no recorded cases of playback issues in ANDROID_MUSIC. So it will be better to use this client instead of IOS to handle Kids video with music
* Example video: https://youtu.be/bHtvEpeXrfc

* fix: Apply code review suggestions

* chore: Lint code

---------

Co-authored-by: inotia00 <108592928+inotia00@users.noreply.github.com>
This commit is contained in:
Hoàng Gia Bảo 2025-01-03 17:26:34 +07:00 committed by GitHub
parent 234695dd70
commit 1da2664513
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 772 additions and 611 deletions

View File

@ -4,6 +4,9 @@ import android.os.Build
import app.revanced.extension.shared.patches.PatchStatus import app.revanced.extension.shared.patches.PatchStatus
import app.revanced.extension.shared.settings.BaseSettings import app.revanced.extension.shared.settings.BaseSettings
/**
* Used to fetch streaming data.
*/
object AppClient { object AppClient {
// IOS // IOS
/** /**
@ -46,7 +49,7 @@ object AppClient {
// IOS UNPLUGGED // IOS UNPLUGGED
/** /**
* Video not playable: Paid / Movie * Video not playable: Paid / Movie / Playlists / Music
* Note: Audio track available * Note: Audio track available
*/ */
private const val PACKAGE_NAME_IOS_UNPLUGGED = "com.google.ios.youtubeunplugged" 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() return BaseSettings.SPOOF_STREAMING_DATA_IOS_FORCE_AVC.get()
} }
@JvmStatic
val availableClientTypes: Array<ClientType> val availableClientTypes: Array<ClientType>
get() = if (PatchStatus.SpoofStreamingDataMusic()) get() = if (PatchStatus.SpoofStreamingDataMusic())
ClientType.CLIENT_ORDER_TO_USE_YOUTUBE_MUSIC ClientType.CLIENT_ORDER_TO_USE_YOUTUBE_MUSIC
@ -185,43 +187,35 @@ object AppClient {
/** /**
* Device model, equivalent to [Build.MODEL] (System property: ro.product.model) * 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) * 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. * Client user-agent.
*/ */
@JvmField
val userAgent: String, val userAgent: String,
/** /**
* Android SDK version, equivalent to [Build.VERSION.SDK] (System property: ro.build.version.sdk) * Android SDK version, equivalent to [Build.VERSION.SDK] (System property: ro.build.version.sdk)
* Field is null if not applicable. * Field is null if not applicable.
*/ */
@JvmField
val androidSdkVersion: String? = null, val androidSdkVersion: String? = null,
/** /**
* App version. * App version.
*/ */
@JvmField
val clientVersion: String, val clientVersion: String,
/** /**
* If the client can access the API logged in. * 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 requirePoToken: Boolean = false,
val usePoToken: Boolean? = false,
/** /**
* Friendly name displayed in stats for nerds. * Friendly name displayed in stats for nerds.
*/ */
@JvmField
val friendlyName: String val friendlyName: String
) { ) {
ANDROID_VR( ANDROID_VR(
@ -260,7 +254,7 @@ object AppClient {
userAgent = USER_AGENT_IOS, userAgent = USER_AGENT_IOS,
clientVersion = CLIENT_VERSION_IOS, clientVersion = CLIENT_VERSION_IOS,
canLogin = false, canLogin = false,
usePoToken = true, requirePoToken = true,
friendlyName = if (forceAVC()) friendlyName = if (forceAVC())
"iOS Force AVC" "iOS Force AVC"
else else
@ -274,7 +268,6 @@ object AppClient {
friendlyName = "Android Music" friendlyName = "Android Music"
); );
@JvmField
val clientName: String = name val clientName: String = name
companion object { companion object {

View File

@ -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
}
}

View File

@ -222,7 +222,6 @@ public class SpoofStreamingDataPatch {
final Long approxDurationMs = approxDurationMsMap.get(videoId); final Long approxDurationMs = approxDurationMsMap.get(videoId);
if (approxDurationMs != null) { if (approxDurationMs != null) {
Logger.printDebug(() -> "Replacing video length: " + approxDurationMs + " for videoId: " + videoId); Logger.printDebug(() -> "Replacing video length: " + approxDurationMs + " for videoId: " + videoId);
approxDurationMsMap.remove(videoId);
return approxDurationMs; return approxDurationMs;
} }
} }

View File

@ -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;
}
}

View File

@ -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
}
}

View File

@ -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.
* <p>
* 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<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 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<ByteBuffer> future;
private StreamingDataRequest(String videoId, Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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 + '\'' + '}';
}
}

View File

@ -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<String, String>, visitorId: String,
botGuardPoToken: String, droidGuardPoToken: String
) {
private val videoId: String
private val future: Future<ByteBuffer?>
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<AppClient.ClientType>
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<String, StreamingDataRequest> = Collections.synchronizedMap(
object : LinkedHashMap<String, StreamingDataRequest>(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<String, StreamingDataRequest>): 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<AppClient.ClientType> = 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<String, String>, 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<String, String>,
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<String, String>, 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
}
}
}

View File

@ -10,13 +10,15 @@ import org.apache.commons.lang3.BooleanUtils;
import app.revanced.extension.shared.utils.Logger; import app.revanced.extension.shared.utils.Logger;
import app.revanced.extension.shared.utils.Utils; import app.revanced.extension.shared.utils.Utils;
import app.revanced.extension.youtube.patches.utils.PatchStatus; 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.settings.Settings;
import app.revanced.extension.youtube.shared.VideoInformation; import app.revanced.extension.youtube.shared.VideoInformation;
import app.revanced.extension.youtube.whitelist.Whitelist; import app.revanced.extension.youtube.whitelist.Whitelist;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public class PlaybackSpeedPatch { 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 final long TOAST_DELAY_MILLISECONDS = 750;
private static long lastTimeSpeedChanged; private static long lastTimeSpeedChanged;
private static boolean isLiveStream; private static boolean isLiveStream;
@ -39,8 +41,8 @@ public class PlaybackSpeedPatch {
/** /**
* Injection point. * Injection point.
*/ */
public static void fetchPlaylistData(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) { public static void fetchMusicRequest(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) {
if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get()) { if (DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC) {
try { try {
final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort(); final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort();
// Shorts shelf in home and subscription feed causes player response hook to be called, // Shorts shelf in home and subscription feed causes player response hook to be called,
@ -50,9 +52,12 @@ public class PlaybackSpeedPatch {
return; return;
} }
PlaylistRequest.fetchRequestIfNeeded(videoId); MusicRequest.fetchRequestIfNeeded(
videoId,
Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC_TYPE.get()
);
} catch (Exception ex) { } catch (Exception ex) {
Logger.printException(() -> "fetchPlaylistData failure", ex); Logger.printException(() -> "fetchMusicRequest failure", ex);
} }
} }
} }
@ -61,15 +66,16 @@ public class PlaybackSpeedPatch {
* Injection point. * Injection point.
*/ */
public static float getPlaybackSpeedInShorts(final float playbackSpeed) { public static float getPlaybackSpeedInShorts(final float playbackSpeed) {
if (!VideoInformation.lastPlayerResponseIsShort()) if (VideoInformation.lastPlayerResponseIsShort() &&
return playbackSpeed; Settings.ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS.get()
if (!Settings.ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS.get()) ) {
return playbackSpeed; float defaultPlaybackSpeed = getDefaultPlaybackSpeed(VideoInformation.getChannelId(), null);
Logger.printDebug(() -> "overridePlaybackSpeed in Shorts: " + defaultPlaybackSpeed);
float defaultPlaybackSpeed = getDefaultPlaybackSpeed(VideoInformation.getChannelId(), null); return defaultPlaybackSpeed;
Logger.printDebug(() -> "overridePlaybackSpeed in Shorts: " + defaultPlaybackSpeed); }
return defaultPlaybackSpeed; return playbackSpeed;
} }
/** /**
@ -118,23 +124,21 @@ public class PlaybackSpeedPatch {
} }
private static float getDefaultPlaybackSpeed(@NonNull String channelId, @Nullable String videoId) { private static float getDefaultPlaybackSpeed(@NonNull String channelId, @Nullable String videoId) {
return (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_LIVE.get() && isLiveStream) || return (isLiveStream || Whitelist.isChannelWhitelistedPlaybackSpeed(channelId) || isMusic(videoId))
Whitelist.isChannelWhitelistedPlaybackSpeed(channelId) ||
getPlaylistData(videoId)
? 1.0f ? 1.0f
: Settings.DEFAULT_PLAYBACK_SPEED.get(); : Settings.DEFAULT_PLAYBACK_SPEED.get();
} }
private static boolean getPlaylistData(@Nullable String videoId) { private static boolean isMusic(@Nullable String videoId) {
if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get() && videoId != null) { if (DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC && videoId != null) {
try { try {
PlaylistRequest request = PlaylistRequest.getRequestForVideoId(videoId); MusicRequest request = MusicRequest.getRequestForVideoId(videoId);
final boolean isPlaylist = request != null && BooleanUtils.toBoolean(request.getStream()); final boolean isMusic = request != null && BooleanUtils.toBoolean(request.getStream());
Logger.printDebug(() -> "isPlaylist: " + isPlaylist); Logger.printDebug(() -> "videoId: " + videoId + ", isMusic: " + isMusic);
return isPlaylist; return isMusic;
} catch (Exception ex) { } catch (Exception ex) {
Logger.printException(() -> "getPlaylistData failure", ex); Logger.printException(() -> "getMusicRequest failure", ex);
} }
} }

View File

@ -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<Boolean> = 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<String, MusicRequest> = 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
}
}
}

View File

@ -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<String, PlaylistRequest> 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<Boolean> 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;
}
}

View File

@ -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_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 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_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 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 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)); 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); public static final BooleanSetting RESTORE_OLD_VIDEO_QUALITY_MENU = new BooleanSetting("revanced_restore_old_video_quality_menu", TRUE, true);
// Experimental Flags // 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 = 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 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 = 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); public static final BooleanSetting SKIP_PRELOADED_BUFFER_TOAST = new BooleanSetting("revanced_skip_preloaded_buffer_toast", TRUE);

View File

@ -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") 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") updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "RememberPlaybackSpeed")

View File

@ -1497,9 +1497,6 @@ No margins on top and bottom of player."</string>
<string name="revanced_disable_hdr_video_title">Disable HDR video</string> <string name="revanced_disable_hdr_video_title">Disable HDR video</string>
<string name="revanced_disable_hdr_video_summary_on">HDR video is disabled.</string> <string name="revanced_disable_hdr_video_summary_on">HDR video is disabled.</string>
<string name="revanced_disable_hdr_video_summary_off">HDR video is enabled.</string> <string name="revanced_disable_hdr_video_summary_off">HDR video is enabled.</string>
<string name="revanced_disable_default_playback_speed_live_title">Disable playback speed for live streams</string>
<string name="revanced_disable_default_playback_speed_live_summary_on">Default playback speed is disabled for live streams.</string>
<string name="revanced_disable_default_playback_speed_live_summary_off">Default playback speed is enabled for live streams.</string>
<string name="revanced_enable_custom_playback_speed_title">Enable custom playback speed</string> <string name="revanced_enable_custom_playback_speed_title">Enable custom playback speed</string>
<string name="revanced_enable_custom_playback_speed_summary_on">Custom playback speed is enabled.</string> <string name="revanced_enable_custom_playback_speed_summary_on">Custom playback speed is enabled.</string>
<string name="revanced_enable_custom_playback_speed_summary_off">Custom playback speed is disabled.</string> <string name="revanced_enable_custom_playback_speed_summary_off">Custom playback speed is disabled.</string>
@ -1524,10 +1521,11 @@ No margins on top and bottom of player."</string>
<string name="revanced_restore_old_video_quality_menu_summary_on">Old video quality menu is shown.</string> <string name="revanced_restore_old_video_quality_menu_summary_on">Old video quality menu is shown.</string>
<string name="revanced_restore_old_video_quality_menu_summary_off">Old video quality menu is not shown.</string> <string name="revanced_restore_old_video_quality_menu_summary_off">Old video quality menu is not shown.</string>
<string name="revanced_disable_default_playback_speed_music_title">Disable playback speed for music</string> <string name="revanced_disable_default_playback_speed_music_title">Disable playback speed for music</string>
<string name="revanced_disable_default_playback_speed_music_on">"Default playback speed is disabled for music. <string name="revanced_disable_default_playback_speed_music_on">Default playback speed is disabled for music.</string>
Limitation: This setting may not apply to videos that do not include the 'Listen on YouTube Music' banner."</string>
<string name="revanced_disable_default_playback_speed_music_off">Default playback speed is enabled for music.</string> <string name="revanced_disable_default_playback_speed_music_off">Default playback speed is enabled for music.</string>
<string name="revanced_disable_default_playback_speed_music_type_title">Validate using categories</string>
<string name="revanced_disable_default_playback_speed_music_type_on">Default playback speed is disabled if the video category is Music.</string>
<string name="revanced_disable_default_playback_speed_music_type_off">Default playback speed is disabled for videos playable on YouTube Music.</string>
<string name="revanced_enable_default_playback_speed_shorts_title">Enable Shorts default playback speed</string> <string name="revanced_enable_default_playback_speed_shorts_title">Enable Shorts default playback speed</string>
<string name="revanced_enable_default_playback_speed_shorts_summary_on">Default playback speed applies to Shorts.</string> <string name="revanced_enable_default_playback_speed_shorts_summary_on">Default playback speed applies to Shorts.</string>
<string name="revanced_enable_default_playback_speed_shorts_summary_off">Default playback speed does not apply to Shorts.</string> <string name="revanced_enable_default_playback_speed_shorts_summary_off">Default playback speed does not apply to Shorts.</string>

View File

@ -684,7 +684,6 @@
<ListPreference android:title="@string/revanced_default_video_quality_mobile_title" android:key="revanced_default_video_quality_mobile" android:entries="@array/revanced_default_video_quality_entries" android:entryValues="@array/revanced_default_video_quality_entry_values" /> <ListPreference android:title="@string/revanced_default_video_quality_mobile_title" android:key="revanced_default_video_quality_mobile" android:entries="@array/revanced_default_video_quality_entries" android:entryValues="@array/revanced_default_video_quality_entry_values" />
<ListPreference android:title="@string/revanced_default_video_quality_wifi_title" android:key="revanced_default_video_quality_wifi" android:entries="@array/revanced_default_video_quality_entries" android:entryValues="@array/revanced_default_video_quality_entry_values" /> <ListPreference android:title="@string/revanced_default_video_quality_wifi_title" android:key="revanced_default_video_quality_wifi" android:entries="@array/revanced_default_video_quality_entries" android:entryValues="@array/revanced_default_video_quality_entry_values" />
<SwitchPreference android:title="@string/revanced_disable_hdr_video_title" android:key="revanced_disable_hdr_video" android:summaryOn="@string/revanced_disable_hdr_video_summary_on" android:summaryOff="@string/revanced_disable_hdr_video_summary_off" /> <SwitchPreference android:title="@string/revanced_disable_hdr_video_title" android:key="revanced_disable_hdr_video" android:summaryOn="@string/revanced_disable_hdr_video_summary_on" android:summaryOff="@string/revanced_disable_hdr_video_summary_off" />
<SwitchPreference android:title="@string/revanced_disable_default_playback_speed_live_title" android:key="revanced_disable_default_playback_speed_live" android:summaryOn="@string/revanced_disable_default_playback_speed_live_summary_on" android:summaryOff="@string/revanced_disable_default_playback_speed_live_summary_off" />
<SwitchPreference android:title="@string/revanced_enable_custom_playback_speed_title" android:key="revanced_enable_custom_playback_speed" android:summaryOn="@string/revanced_enable_custom_playback_speed_summary_on" android:summaryOff="@string/revanced_enable_custom_playback_speed_summary_off" /> <SwitchPreference android:title="@string/revanced_enable_custom_playback_speed_title" android:key="revanced_enable_custom_playback_speed" android:summaryOn="@string/revanced_enable_custom_playback_speed_summary_on" android:summaryOff="@string/revanced_enable_custom_playback_speed_summary_off" />
<SwitchPreference android:title="@string/revanced_custom_playback_speed_menu_type_title" android:key="revanced_custom_playback_speed_menu_type" android:summaryOn="@string/revanced_custom_playback_speed_menu_type_summary_on" android:summaryOff="@string/revanced_custom_playback_speed_menu_type_summary_off" /> <SwitchPreference android:title="@string/revanced_custom_playback_speed_menu_type_title" android:key="revanced_custom_playback_speed_menu_type" android:summaryOn="@string/revanced_custom_playback_speed_menu_type_summary_on" android:summaryOff="@string/revanced_custom_playback_speed_menu_type_summary_off" />
<app.revanced.extension.shared.settings.preference.ResettableEditTextPreference android:title="@string/revanced_custom_playback_speeds_title" android:key="revanced_custom_playback_speeds" android:summary="@string/revanced_custom_playback_speeds_summary" android:inputType="textMultiLine" /> <app.revanced.extension.shared.settings.preference.ResettableEditTextPreference android:title="@string/revanced_custom_playback_speeds_title" android:key="revanced_custom_playback_speeds" android:summary="@string/revanced_custom_playback_speeds_summary" android:inputType="textMultiLine" />
@ -695,6 +694,7 @@
<SwitchPreference android:title="@string/revanced_restore_old_video_quality_menu_title" android:key="revanced_restore_old_video_quality_menu" android:summaryOn="@string/revanced_restore_old_video_quality_menu_summary_on" android:summaryOff="@string/revanced_restore_old_video_quality_menu_summary_off" /> <SwitchPreference android:title="@string/revanced_restore_old_video_quality_menu_title" android:key="revanced_restore_old_video_quality_menu" android:summaryOn="@string/revanced_restore_old_video_quality_menu_summary_on" android:summaryOff="@string/revanced_restore_old_video_quality_menu_summary_off" />
<PreferenceCategory android:title="@string/revanced_preference_category_experimental_flag" android:layout="@layout/revanced_settings_preferences_category"/> <PreferenceCategory android:title="@string/revanced_preference_category_experimental_flag" android:layout="@layout/revanced_settings_preferences_category"/>
<SwitchPreference android:title="@string/revanced_disable_default_playback_speed_music_title" android:key="revanced_disable_default_playback_speed_music" android:summaryOn="@string/revanced_disable_default_playback_speed_music_on" android:summaryOff="@string/revanced_disable_default_playback_speed_music_off" /> <SwitchPreference android:title="@string/revanced_disable_default_playback_speed_music_title" android:key="revanced_disable_default_playback_speed_music" android:summaryOn="@string/revanced_disable_default_playback_speed_music_on" android:summaryOff="@string/revanced_disable_default_playback_speed_music_off" />
<SwitchPreference android:title="@string/revanced_disable_default_playback_speed_music_type_title" android:key="revanced_disable_default_playback_speed_music_type" android:summaryOn="@string/revanced_disable_default_playback_speed_music_type_on" android:summaryOff="@string/revanced_disable_default_playback_speed_music_type_off" />
<SwitchPreference android:title="@string/revanced_enable_default_playback_speed_shorts_title" android:key="revanced_enable_default_playback_speed_shorts" android:summaryOn="@string/revanced_enable_default_playback_speed_shorts_summary_on" android:summaryOff="@string/revanced_enable_default_playback_speed_shorts_summary_off" /> <SwitchPreference android:title="@string/revanced_enable_default_playback_speed_shorts_title" android:key="revanced_enable_default_playback_speed_shorts" android:summaryOn="@string/revanced_enable_default_playback_speed_shorts_summary_on" android:summaryOff="@string/revanced_enable_default_playback_speed_shorts_summary_off" />
<SwitchPreference android:title="@string/revanced_skip_preloaded_buffer_title" android:key="revanced_skip_preloaded_buffer" android:summary="@string/revanced_skip_preloaded_buffer_summary" /> <SwitchPreference android:title="@string/revanced_skip_preloaded_buffer_title" android:key="revanced_skip_preloaded_buffer" android:summary="@string/revanced_skip_preloaded_buffer_summary" />
<SwitchPreference android:title="@string/revanced_skip_preloaded_buffer_toast_title" android:key="revanced_skip_preloaded_buffer_toast" android:summaryOn="@string/revanced_skip_preloaded_buffer_toast_summary_on" android:summaryOff="@string/revanced_skip_preloaded_buffer_toast_summary_off" android:dependency="revanced_skip_preloaded_buffer" /> <SwitchPreference android:title="@string/revanced_skip_preloaded_buffer_toast_title" android:key="revanced_skip_preloaded_buffer_toast" android:summaryOn="@string/revanced_skip_preloaded_buffer_toast_summary_on" android:summaryOff="@string/revanced_skip_preloaded_buffer_toast_summary_off" android:dependency="revanced_skip_preloaded_buffer" />