fix(YouTube Music - Disable music video in album): Piped API not available

This commit is contained in:
inotia00
2025-03-06 12:06:15 +09:00
parent 6a42fea64a
commit 42d0765534
5 changed files with 235 additions and 190 deletions

View File

@ -9,7 +9,7 @@ import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import app.revanced.extension.music.patches.misc.requests.PipedRequester;
import app.revanced.extension.music.patches.misc.requests.PlaylistRequest;
import app.revanced.extension.music.settings.Settings;
import app.revanced.extension.music.shared.VideoInformation;
import app.revanced.extension.music.utils.VideoUtils;
@ -76,8 +76,8 @@ public class AlbumMusicVideoPatch {
}
playerResponseVideoId = videoId;
// Fetch Piped instance.
PipedRequester.fetchRequestIfNeeded(videoId, playlistId, playlistIndex);
// Fetch.
PlaylistRequest.fetchRequestIfNeeded(videoId, playlistId, playlistIndex);
}
/**
@ -96,7 +96,7 @@ public class AlbumMusicVideoPatch {
private static void checkVideo(@NonNull String videoId) {
try {
PipedRequester request = PipedRequester.getRequestForVideoId(videoId);
PlaylistRequest request = PlaylistRequest.getRequestForVideoId(videoId);
if (request == null) {
return;
}
@ -109,7 +109,7 @@ public class AlbumMusicVideoPatch {
Logger.printException(() -> "Error: Blocking main thread");
}
String songId = request.getStream();
if (songId == null) {
if (songId.isEmpty()) {
Logger.printDebug(() -> "Official song not found, videoId: " + videoId);
return;
}
@ -157,7 +157,7 @@ public class AlbumMusicVideoPatch {
playerResponseVideoId = songId;
currentVideoId = songId;
VideoUtils.openInYouTubeMusic(songId);
}, 750);
}, 1000);
VideoUtils.runOnMainThreadDelayed(() -> isVideoLaunched.compareAndSet(true, false), 1500);
} catch (Exception ex) {

View File

@ -1,159 +0,0 @@
package app.revanced.extension.music.patches.misc.requests;
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.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.shared.utils.Logger;
import app.revanced.extension.shared.utils.Utils;
public class PipedRequester {
private static final long MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 4 * 1000; // 4 seconds
@GuardedBy("itself")
private static final Map<String, PipedRequester> cache = new LinkedHashMap<>() {
private static final int NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK = 10;
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK;
}
};
@SuppressLint("ObsoleteSdkInt")
public static void fetchRequestIfNeeded(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) {
synchronized (cache) {
if (!cache.containsKey(videoId)) {
PipedRequester pipedRequester = new PipedRequester(videoId, playlistId, playlistIndex);
cache.put(videoId, pipedRequester);
}
}
}
@Nullable
public static PipedRequester getRequestForVideoId(@Nullable String videoId) {
synchronized (cache) {
return cache.get(videoId);
}
}
/**
* TCP timeout
*/
private static final int TIMEOUT_TCP_DEFAULT_MILLISECONDS = 2 * 1000; // 2 seconds
/**
* HTTP response timeout
*/
private static final int TIMEOUT_HTTP_DEFAULT_MILLISECONDS = 4 * 1000; // 4 seconds
@Nullable
private static JSONObject send(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) {
final long startTime = System.currentTimeMillis();
Logger.printDebug(() -> "Fetching piped instances (videoId: '" + videoId +
"', playlistId: '" + playlistId + "', playlistIndex: '" + playlistIndex + "')");
try {
HttpURLConnection connection = PipedRoutes.getPlaylistConnectionFromRoute(playlistId);
connection.setConnectTimeout(TIMEOUT_TCP_DEFAULT_MILLISECONDS);
connection.setReadTimeout(TIMEOUT_HTTP_DEFAULT_MILLISECONDS);
final int responseCode = connection.getResponseCode();
if (responseCode == 200) return Requester.parseJSONObject(connection);
handleConnectionError("API not available: " + responseCode);
} 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(() -> "playlist: " + playlistId + " took: " + (System.currentTimeMillis() - startTime) + "ms");
}
return null;
}
@Nullable
private static String fetch(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) {
final JSONObject playlistJson = send(videoId, playlistId, playlistIndex);
if (playlistJson != null) {
try {
final String songId = playlistJson.getJSONArray("relatedStreams")
.getJSONObject(playlistIndex)
.getString("url")
.replaceAll("/.+=", "");
if (songId.isEmpty()) {
handleConnectionError("Url is empty!");
} else if (!songId.equals(videoId)) {
Logger.printDebug(() -> "Video found (videoId: '" + videoId +
"', songId: '" + songId + "')");
return songId;
}
} catch (JSONException e) {
Logger.printDebug(() -> "Fetch failed while processing response data for response: " + playlistJson);
}
}
return null;
}
private static void handleConnectionError(@NonNull String errorMessage) {
handleConnectionError(errorMessage, null);
}
private static void handleConnectionError(@NonNull String errorMessage, @Nullable Exception ex) {
if (ex != null) {
Logger.printInfo(() -> errorMessage, ex);
}
}
/**
* Time this instance and the fetch future was created.
*/
private final Future<String> future;
private PipedRequester(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) {
this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playlistId, playlistIndex));
}
/**
* @return if the fetch call has completed.
*/
public boolean fetchCompleted() {
return future.isDone();
}
public String 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

@ -1,22 +0,0 @@
package app.revanced.extension.music.patches.misc.requests;
import static app.revanced.extension.shared.requests.Route.Method.GET;
import java.io.IOException;
import java.net.HttpURLConnection;
import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.shared.requests.Route;
class PipedRoutes {
private static final String PIPED_URL = "https://pipedapi.kavin.rocks/";
private static final Route GET_PLAYLIST = new Route(GET, "playlists/{playlist_id}");
private PipedRoutes() {
}
static HttpURLConnection getPlaylistConnectionFromRoute(String... params) throws IOException {
return Requester.getConnectionFromRoute(PIPED_URL, GET_PLAYLIST, params);
}
}

View File

@ -0,0 +1,226 @@
package app.revanced.extension.music.patches.misc.requests
import android.annotation.SuppressLint
import androidx.annotation.GuardedBy
import app.revanced.extension.shared.patches.client.YouTubeAppClient
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 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 PlaylistRequest private constructor(
private val videoId: String,
private val playlistId: String,
private val playlistIndex: Int,
) {
/**
* Time this instance and the fetch future was created.
*/
private val timeFetched = System.currentTimeMillis()
private val future: Future<String> = Utils.submitOnBackgroundThread {
fetch(
videoId,
playlistId,
playlistIndex,
)
}
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.isEmpty())
}
/**
* @return if the fetch call has completed.
*/
fun fetchCompleted(): Boolean {
return future.isDone
}
val stream: String
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 ""
}
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 = 10 * 1000L // 10 seconds
@GuardedBy("itself")
private val cache: MutableMap<String, PlaylistRequest> = HashMap()
@JvmStatic
@SuppressLint("ObsoleteSdkInt")
fun fetchRequestIfNeeded(
videoId: String,
playlistId: String,
playlistIndex: Int,
) {
Objects.requireNonNull(videoId)
synchronized(cache) {
val now = System.currentTimeMillis()
cache.values.removeIf { request: PlaylistRequest ->
val expired = request.isExpired(now)
if (expired) Logger.printDebug { "Removing expired stream: " + request.videoId }
expired
}
if (!cache.containsKey(videoId)) {
cache[videoId] = PlaylistRequest(
videoId,
playlistId,
playlistIndex,
)
}
}
}
@JvmStatic
fun getRequestForVideoId(videoId: String): PlaylistRequest? {
synchronized(cache) {
return cache[videoId]
}
}
private fun handleConnectionError(toastMessage: String, ex: Exception?) {
Logger.printInfo({ toastMessage }, ex)
}
private fun sendRequest(
videoId: String,
playlistId: String,
): JSONObject? {
Objects.requireNonNull(videoId)
val startTime = System.currentTimeMillis()
val clientType = YouTubeAppClient.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
)
val requestBody =
PlayerRoutes.createApplicationRequestBody(
clientType = clientType,
videoId = videoId,
playlistId = playlistId
)
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({ "sendRequest failed" }, ex)
} finally {
Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" }
}
return null
}
private fun parseResponse(playlistJson: JSONObject, playlistIndex: Int): String {
try {
val singleColumnWatchNextResultsJsonObject: JSONObject =
playlistJson
.getJSONObject("contents")
.getJSONObject("singleColumnWatchNextResults")
if (singleColumnWatchNextResultsJsonObject.has("playlist")) {
val playlistJsonObject: JSONObject? =
singleColumnWatchNextResultsJsonObject
.getJSONObject("playlist")
.getJSONObject("playlist")
val currentStreamJsonObject = playlistJsonObject
?.getJSONArray("contents")
?.get(playlistIndex)
if (currentStreamJsonObject is JSONObject) {
val watchEndpointJsonObject: JSONObject? =
currentStreamJsonObject
.getJSONObject("playlistPanelVideoRenderer")
.getJSONObject("navigationEndpoint")
.getJSONObject("watchEndpoint")
return watchEndpointJsonObject?.getString("videoId") + ""
}
}
} catch (e: JSONException) {
Logger.printException(
{ "Fetch failed while processing response data for response: $playlistJson" },
e
)
}
return ""
}
private fun fetch(
videoId: String,
playlistId: String,
playlistIndex: Int,
): String {
val playlistJson = sendRequest(
videoId,
playlistId,
)
if (playlistJson != null) {
return parseResponse(playlistJson, playlistIndex)
}
return ""
}
}
}