feat(YouTube Music): Add Disable music video in album patch https://github.com/inotia00/ReVanced_Extended/issues/2568

This commit is contained in:
inotia00 2025-01-03 20:04:50 +09:00
parent 1da2664513
commit 8ab67bc6ef
12 changed files with 549 additions and 0 deletions

View File

@ -0,0 +1,109 @@
package app.revanced.extension.music.patches.misc;
import androidx.annotation.NonNull;
import java.util.concurrent.atomic.AtomicBoolean;
import app.revanced.extension.music.patches.misc.requests.PipedRequester;
import app.revanced.extension.music.settings.Settings;
import app.revanced.extension.music.utils.VideoUtils;
import app.revanced.extension.shared.utils.Logger;
@SuppressWarnings("unused")
public class AlbumMusicVideoPatch {
private static final String YOUTUBE_MUSIC_ALBUM_PREFIX = "OLAK";
private static final boolean DISABLE_MUSIC_VIDEO_IN_ALBUM =
Settings.DISABLE_MUSIC_VIDEO_IN_ALBUM.get();
private static final AtomicBoolean isVideoLaunched = new AtomicBoolean(false);
@NonNull
private static volatile String playerResponseVideoId = "";
@NonNull
private static volatile String currentVideoId = "";
/**
* Injection point.
*/
public static void newPlayerResponse(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) {
if (!DISABLE_MUSIC_VIDEO_IN_ALBUM) {
return;
}
if (!playlistId.startsWith(YOUTUBE_MUSIC_ALBUM_PREFIX)) {
return;
}
if (playlistIndex < 0) {
return;
}
if (playerResponseVideoId.equals(videoId)) {
return;
}
playerResponseVideoId = videoId;
// Fetch from piped instances.
PipedRequester.fetchRequestIfNeeded(videoId, playlistId, playlistIndex);
}
/**
* Injection point.
*/
public static void newVideoLoaded(@NonNull String videoId) {
if (!DISABLE_MUSIC_VIDEO_IN_ALBUM) {
return;
}
if (currentVideoId.equals(videoId)) {
return;
}
currentVideoId = videoId;
// If the user is using a not fast enough internet connection, there will be a slight delay.
// Otherwise, the video may open repeatedly.
VideoUtils.runOnMainThreadDelayed(() -> openOfficialMusicIfNeeded(videoId), 750);
}
private static void openOfficialMusicIfNeeded(@NonNull String videoId) {
try {
PipedRequester request = PipedRequester.getRequestForVideoId(videoId);
if (request == null) {
return;
}
String songId = request.getStream();
if (songId == null) {
return;
}
// It is handled by YouTube Music's internal code.
// There is a slight delay before the dismiss request is reflected.
VideoUtils.dismissQueue();
// Every time a new video is opened, a snack bar appears indicating that the account has been switched.
// To prevent this, hide the snack bar while a new video is opening.
isVideoLaunched.compareAndSet(false, true);
// The newly opened video is not a music video.
// To prevent fetch requests from being sent, set the video id to the newly opened video
VideoUtils.runOnMainThreadDelayed(() -> {
playerResponseVideoId = songId;
currentVideoId = songId;
VideoUtils.openInYouTubeMusic(songId);
}, 750);
// If a new video is opened, the snack bar will be shown.
VideoUtils.runOnMainThreadDelayed(() -> isVideoLaunched.compareAndSet(true, false), 1500);
} catch (Exception ex) {
Logger.printException(() -> "openOfficialMusicIfNeeded failure", ex);
}
}
/**
* Injection point.
*/
public static boolean hideSnackBar() {
if (!DISABLE_MUSIC_VIDEO_IN_ALBUM) {
return false;
}
return isVideoLaunched.get();
}
}

View File

@ -0,0 +1,177 @@
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.HashMap;
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 {
/**
* 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, PipedRequester> cache = new HashMap<>();
@SuppressLint("ObsoleteSdkInt")
public static void fetchRequestIfNeeded(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) {
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)) {
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)) {
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 long timeFetched;
private final String videoId;
private final Future<String> future;
private PipedRequester(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) {
this.timeFetched = System.currentTimeMillis();
this.videoId = videoId;
this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playlistId, playlistIndex));
}
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 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

@ -0,0 +1,22 @@
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

@ -178,6 +178,7 @@ public class Settings extends BaseSettings {
public static final BooleanSetting CHANGE_SHARE_SHEET = new BooleanSetting("revanced_change_share_sheet", FALSE, true);
public static final BooleanSetting DISABLE_CAIRO_SPLASH_ANIMATION = new BooleanSetting("revanced_disable_cairo_splash_animation", FALSE, true);
public static final BooleanSetting DISABLE_DRC_AUDIO = new BooleanSetting("revanced_disable_drc_audio", FALSE, true);
public static final BooleanSetting DISABLE_MUSIC_VIDEO_IN_ALBUM = new BooleanSetting("revanced_disable_music_video_in_album", FALSE, true);
public static final BooleanSetting ENABLE_OPUS_CODEC = new BooleanSetting("revanced_enable_opus_codec", FALSE, true);
public static final BooleanSetting SETTINGS_IMPORT_EXPORT = new BooleanSetting("revanced_extended_settings_import_export", FALSE, false);
public static final BooleanSetting SPOOF_CLIENT = new BooleanSetting("revanced_spoof_client", FALSE, true);

View File

@ -71,6 +71,13 @@ public class VideoUtils extends IntentUtils {
launchView(url, context.getPackageName());
}
/**
* Rest of the implementation added by patch.
*/
public static void dismissQueue() {
Log.d("Extended: VideoUtils", "Queue dismissed");
}
/**
* Rest of the implementation added by patch.
*/

View File

@ -0,0 +1,85 @@
package app.revanced.patches.music.misc.album
import app.revanced.patcher.extensions.InstructionExtensions.addInstruction
import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE
import app.revanced.patches.music.utils.dismiss.dismissQueueHookPatch
import app.revanced.patches.music.utils.extension.Constants.MISC_PATH
import app.revanced.patches.music.utils.patch.PatchList.DISABLE_MUSIC_VIDEO_IN_ALBUM
import app.revanced.patches.music.utils.playservice.is_7_03_or_greater
import app.revanced.patches.music.utils.playservice.versionCheckPatch
import app.revanced.patches.music.utils.settings.CategoryType
import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus
import app.revanced.patches.music.utils.settings.addSwitchPreference
import app.revanced.patches.music.utils.settings.settingsPatch
import app.revanced.patches.music.video.information.videoIdHook
import app.revanced.patches.music.video.information.videoInformationPatch
import app.revanced.util.fingerprint.methodOrThrow
private const val EXTENSION_CLASS_DESCRIPTOR =
"$MISC_PATH/AlbumMusicVideoPatch;"
@Suppress("unused")
val albumMusicVideoPatch = bytecodePatch(
DISABLE_MUSIC_VIDEO_IN_ALBUM.title,
DISABLE_MUSIC_VIDEO_IN_ALBUM.summary,
false,
) {
compatibleWith(COMPATIBLE_PACKAGE)
dependsOn(
settingsPatch,
dismissQueueHookPatch,
videoInformationPatch,
versionCheckPatch,
)
execute {
// region hook player response
val fingerprint = if (is_7_03_or_greater) {
playerParameterBuilderFingerprint
} else {
playerParameterBuilderLegacyFingerprint
}
fingerprint.methodOrThrow().addInstruction(
0,
"invoke-static {p1, p4, p5}, $EXTENSION_CLASS_DESCRIPTOR->newPlayerResponse(Ljava/lang/String;Ljava/lang/String;I)V"
)
// endregion
// region hook video id
videoIdHook("$EXTENSION_CLASS_DESCRIPTOR->newVideoLoaded(Ljava/lang/String;)V")
// endregion
// region patch for hide snack bar
snackBarParentFingerprint.methodOrThrow().addInstructionsWithLabels(
0, """
invoke-static {}, $EXTENSION_CLASS_DESCRIPTOR->hideSnackBar()Z
move-result v0
if-eqz v0, :hide
return-void
:hide
nop
"""
)
// endregion
addSwitchPreference(
CategoryType.MISC,
"revanced_disable_music_video_in_album",
"false"
)
updatePatchStatus(DISABLE_MUSIC_VIDEO_IN_ALBUM)
}
}

View File

@ -0,0 +1,69 @@
package app.revanced.patches.music.misc.album
import app.revanced.util.fingerprint.legacyFingerprint
import app.revanced.util.or
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
/**
* For targets 7.03 and later.
*/
internal val playerParameterBuilderFingerprint = legacyFingerprint(
name = "playerParameterBuilderFingerprint",
accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
returnType = "L",
parameters = listOf(
"Ljava/lang/String;", // VideoId.
"[B",
"Ljava/lang/String;", // Player parameters proto buffer.
"Ljava/lang/String;", // PlaylistId.
"I", // PlaylistIndex.
"I",
"L",
"Ljava/util/Set;",
"Ljava/lang/String;",
"Ljava/lang/String;",
"L",
"Z",
"Z",
"Z", // Appears to indicate if the video id is being opened or is currently playing.
),
strings = listOf("psps")
)
/**
* For targets 7.02 and earlier.
*/
internal val playerParameterBuilderLegacyFingerprint = legacyFingerprint(
name = "playerParameterBuilderLegacyFingerprint",
accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
returnType = "L",
parameters = listOf(
"Ljava/lang/String;", // VideoId.
"[B",
"Ljava/lang/String;", // Player parameters proto buffer.
"Ljava/lang/String;", // PlaylistId.
"I", // PlaylistIndex.
"I",
"Ljava/util/Set;",
"Ljava/lang/String;",
"Ljava/lang/String;",
"L",
"Z",
"Z", // Appears to indicate if the video id is being opened or is currently playing.
),
opcodes = listOf(
Opcode.INVOKE_INTERFACE,
Opcode.MOVE_RESULT_OBJECT,
Opcode.CHECK_CAST,
Opcode.INVOKE_INTERFACE
)
)
internal val snackBarParentFingerprint = legacyFingerprint(
name = "snackBarParentFingerprint",
accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
returnType = "V",
parameters = listOf("L"),
strings = listOf("No suitable parent found from the given view. Please provide a valid view.")
)

View File

@ -0,0 +1,42 @@
package app.revanced.patches.music.utils.dismiss
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.music.utils.extension.Constants.EXTENSION_PATH
import app.revanced.util.addStaticFieldToExtension
import app.revanced.util.fingerprint.methodOrThrow
import app.revanced.util.getWalkerMethod
private const val EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR =
"$EXTENSION_PATH/utils/VideoUtils;"
@Suppress("unused")
val dismissQueueHookPatch = bytecodePatch(
description = "dismissQueueHookPatch"
) {
execute {
dismissQueueFingerprint.methodOrThrow().apply {
val dismissQueueIndex = indexOfDismissQueueInstruction(this)
getWalkerMethod(dismissQueueIndex).apply {
val smaliInstructions =
"""
if-eqz v0, :ignore
invoke-virtual {v0}, $definingClass->$name()V
:ignore
return-void
"""
addStaticFieldToExtension(
EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR,
"dismissQueue",
"dismissQueueClass",
definingClass,
smaliInstructions
)
}
}
}
}

View File

@ -0,0 +1,24 @@
package app.revanced.patches.music.utils.dismiss
import app.revanced.util.fingerprint.legacyFingerprint
import app.revanced.util.getReference
import app.revanced.util.indexOfFirstInstruction
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.Method
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
internal val dismissQueueFingerprint = legacyFingerprint(
name = "dismissQueueFingerprint",
returnType = "V",
parameters = listOf("L"),
customFingerprint = { method, _ ->
method.name == "handleDismissWatchEvent" &&
indexOfDismissQueueInstruction(method) >= 0
}
)
internal fun indexOfDismissQueueInstruction(method: Method) =
method.indexOfFirstInstruction {
opcode == Opcode.INVOKE_VIRTUAL &&
getReference<MethodReference>()?.definingClass?.endsWith("/MppWatchWhileLayout;") == true
}

View File

@ -57,6 +57,10 @@ internal enum class PatchList(
"Disable dislike redirection",
"Adds an option to disable redirection to the next track when clicking the Dislike button."
),
DISABLE_MUSIC_VIDEO_IN_ALBUM(
"Disable music video in album",
"Adds option to redirect music videos from albums."
),
ENABLE_OPUS_CODEC(
"Enable OPUS codec",
"Adds an options to enable the OPUS audio codec if the player response includes."

View File

@ -11,6 +11,8 @@ var is_6_36_or_greater = false
private set
var is_6_42_or_greater = false
private set
var is_7_03_or_greater = false
private set
var is_7_06_or_greater = false
private set
var is_7_13_or_greater = false
@ -43,6 +45,7 @@ val versionCheckPatch = resourcePatch(
is_6_27_or_greater = 234412000 <= playStoreServicesVersion
is_6_36_or_greater = 240399000 <= playStoreServicesVersion
is_6_42_or_greater = 240999000 <= playStoreServicesVersion
is_7_03_or_greater = 242199000 <= playStoreServicesVersion
is_7_06_or_greater = 242499000 <= playStoreServicesVersion
is_7_13_or_greater = 243199000 <= playStoreServicesVersion
is_7_17_or_greater = 243530000 <= playStoreServicesVersion

View File

@ -420,6 +420,12 @@ Click to see how to issue a API key."</string>
<string name="revanced_disable_cairo_splash_animation_summary">Disables Cairo splash animation when the app starts up.</string>
<string name="revanced_disable_drc_audio_title">Disable DRC audio</string>
<string name="revanced_disable_drc_audio_summary">Disables DRC (Dynamic Range Compression) applied to audio.</string>
<string name="revanced_disable_music_video_in_album_title">Disable music video in album</string>
<string name="revanced_disable_music_video_in_album_summary">"When a non-premium user plays a song included in an album, the music video is sometimes played instead of the official song.
If such a music video is detected playing, it is redirected to the official song.
A piped instance is used, but the API may not be available in some regions."</string>
<string name="revanced_enable_debug_logging_title">Enable debug logging</string>
<string name="revanced_enable_debug_logging_summary">Prints the debug log.</string>
<string name="revanced_enable_debug_buffer_logging_title">Enable debug buffer logging</string>