Skip to content

Commit

Permalink
feat(YouTube Music): Add Disable music video in album patch
Browse files Browse the repository at this point in the history
  • Loading branch information
inotia00 authored and anddea committed Jan 5, 2025
1 parent 155f223 commit 4cfe879
Show file tree
Hide file tree
Showing 19 changed files with 859 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package app.revanced.extension.music.patches.misc;

import android.view.View;

import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;

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.settings.Settings;
import app.revanced.extension.music.shared.VideoInformation;
import app.revanced.extension.music.utils.VideoUtils;
import app.revanced.extension.shared.utils.Logger;

@SuppressWarnings("unused")
public class AlbumMusicVideoPatch {

public enum RedirectType {
REDIRECT_DISMISS(true),
REDIRECT(false),
ON_CLICK_DISMISS(true),
ON_CLICK(false),
ON_LONG_CLICK_DISMISS(true),
ON_LONG_CLICK(false);

public final boolean dismissQueue;

RedirectType(boolean dismissQueue) {
this.dismissQueue = dismissQueue;
}
}

private static final RedirectType REDIRECT_TYPE =
Settings.DISABLE_MUSIC_VIDEO_IN_ALBUM_REDIRECT_TYPE.get();

private static final boolean DISABLE_MUSIC_VIDEO_IN_ALBUM =
Settings.DISABLE_MUSIC_VIDEO_IN_ALBUM.get();

private static final boolean DISMISS_QUEUE =
DISABLE_MUSIC_VIDEO_IN_ALBUM && REDIRECT_TYPE.dismissQueue;

private static final boolean REDIRECT =
REDIRECT_TYPE == RedirectType.REDIRECT || REDIRECT_TYPE == RedirectType.REDIRECT_DISMISS;

private static final boolean ON_CLICK =
REDIRECT_TYPE == RedirectType.ON_CLICK || REDIRECT_TYPE == RedirectType.ON_CLICK_DISMISS;

private static final boolean ON_LONG_CLICK =
REDIRECT_TYPE == RedirectType.ON_LONG_CLICK || REDIRECT_TYPE == RedirectType.ON_LONG_CLICK_DISMISS;

private static final String YOUTUBE_MUSIC_ALBUM_PREFIX = "OLAK";

private static final AtomicBoolean isVideoLaunched = new AtomicBoolean(false);

@NonNull
private static volatile String playerResponseVideoId = "";

@NonNull
private static volatile String currentVideoId = "";

@GuardedBy("itself")
private static final Map<String, String> lastVideoIds = new LinkedHashMap<>() {
private static final int NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK = 5;

@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK;
}
};

/**
* 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 Piped instance.
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;
checkVideo(videoId);
}

private static void checkVideo(@NonNull String videoId) {
try {
PipedRequester request = PipedRequester.getRequestForVideoId(videoId);
if (request == null) {
return;
}
String songId = request.getStream();
if (songId == null) {
return;
}
synchronized (lastVideoIds) {
if (lastVideoIds.put(videoId, songId) == null) {
Logger.printDebug(() -> "Official song found, videoId: " + videoId + ", songId: " + songId);
if (REDIRECT) {
openMusic(songId);
}
}
}
} catch (Exception ex) {
Logger.printException(() -> "check failure", ex);
}
}

/**
* Injection point.
*/
public static boolean openMusic() {
if (DISABLE_MUSIC_VIDEO_IN_ALBUM && ON_CLICK) {
try {
String videoId = VideoInformation.getVideoId();
synchronized (lastVideoIds) {
String songId = lastVideoIds.get(videoId);
if (songId != null) {
openMusic(songId);
return true;
}
}
} catch (Exception ex) {
Logger.printException(() -> "openMusic failure", ex);
}
}
return false;
}

private static void openMusic(@NonNull String songId) {
try {
if (DISMISS_QUEUE) {
VideoUtils.dismissQueue();
}

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);
}, 500);

VideoUtils.runOnMainThreadDelayed(() -> isVideoLaunched.compareAndSet(true, false), 1500);
} catch (Exception ex) {
Logger.printException(() -> "openMusic failure", ex);
}
}

/**
* Injection point.
*/
public static void setAudioVideoSwitchToggleOnLongClickListener(View view) {
if (DISABLE_MUSIC_VIDEO_IN_ALBUM && ON_LONG_CLICK) {
view.setOnLongClickListener(v -> {
try {
String videoId = VideoInformation.getVideoId();
synchronized (lastVideoIds) {
String songId = lastVideoIds.get(videoId);
if (songId != null) {
openMusic(songId);
}
}
} catch (Exception ex) {
Logger.printException(() -> "onLongClickListener failure", ex);
}
return true;
});
}
}

/**
* Injection point.
*/
public static boolean hideSnackBar() {
return DISABLE_MUSIC_VIDEO_IN_ALBUM && isVideoLaunched.get();
}

}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 4cfe879

Please sign in to comment.