fix(YouTube - ReturnYouTubeDislike): Show estimated like count for videos with hidden likes (#684)

This commit is contained in:
LisoUseInAIKyrios 2024-09-01 17:49:15 -04:00 committed by GitHub
parent 55c278dc08
commit 27d2b60444
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 259 additions and 134 deletions

View File

@ -363,6 +363,23 @@ public class Utils {
return isRightToLeftTextLayout; return isRightToLeftTextLayout;
} }
/**
* @return if the text contains at least 1 number character,
* including any unicode numbers such as Arabic.
*/
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public static boolean containsNumber(@NonNull CharSequence text) {
for (int index = 0, length = text.length(); index < length;) {
final int codePoint = Character.codePointAt(text, index);
if (Character.isDigit(codePoint)) {
return true;
}
index += Character.charCount(codePoint);
}
return false;
}
/** /**
* Safe to call from any thread * Safe to call from any thread
*/ */

View File

@ -225,7 +225,6 @@ public class ReturnYouTubeDislikePatch {
return original; return original;
} }
final CharSequence replacement;
if (conversionContextString.contains("segmented_like_dislike_button.eml")) { if (conversionContextString.contains("segmented_like_dislike_button.eml")) {
// Regular video. // Regular video.
ReturnYouTubeDislike videoData = currentVideoData; ReturnYouTubeDislike videoData = currentVideoData;
@ -235,46 +234,62 @@ public class ReturnYouTubeDislikePatch {
if (!(original instanceof Spanned)) { if (!(original instanceof Spanned)) {
original = new SpannableString(original); original = new SpannableString(original);
} }
replacement = videoData.getDislikesSpanForRegularVideo((Spanned) original, return videoData.getDislikesSpanForRegularVideo((Spanned) original,
true, isRollingNumber); true, isRollingNumber);
} else if (!isRollingNumber && conversionContextString.contains("|shorts_dislike_button.eml|")) {
// Litho Shorts player.
if (!Settings.RYD_SHORTS.get() || Settings.HIDE_SHORTS_DISLIKE_BUTTON.get()) {
// Must clear the current video here, otherwise if the user opens a regular video
// then opens a litho short (while keeping the regular video on screen), then closes the short,
// the original video may show the incorrect dislike value.
clearData();
return original;
}
ReturnYouTubeDislike videoData = lastLithoShortsVideoData;
if (videoData == null) {
// The Shorts litho video id filter did not detect the video id.
// This is normal in incognito mode, but otherwise is abnormal.
Logger.printDebug(() -> "Cannot modify Shorts litho span, data is null");
return original;
}
// Use the correct dislikes data after voting.
if (lithoShortsShouldUseCurrentData) {
lithoShortsShouldUseCurrentData = false;
videoData = currentVideoData;
if (videoData == null) {
Logger.printException(() -> "currentVideoData is null"); // Should never happen
return original;
}
Logger.printDebug(() -> "Using current video data for litho span");
}
replacement = videoData.getDislikeSpanForShort((Spanned) original);
} else {
return original;
} }
return replacement; if (isRollingNumber) {
return original; // No need to check for Shorts in the context.
}
if (conversionContextString.contains("|shorts_dislike_button.eml")) {
return getShortsSpan(original, true);
}
if (conversionContextString.contains("|shorts_like_button.eml")
&& !Utils.containsNumber(original)) {
Logger.printDebug(() -> "Replacing hidden likes count");
return getShortsSpan(original, false);
}
} catch (Exception ex) { } catch (Exception ex) {
Logger.printException(() -> "onLithoTextLoaded failure", ex); Logger.printException(() -> "onLithoTextLoaded failure", ex);
} }
return original; return original;
} }
private static CharSequence getShortsSpan(@NonNull CharSequence original, boolean isDislikesSpan) {
// Litho Shorts player.
if (!Settings.RYD_SHORTS.get() || (isDislikesSpan && Settings.HIDE_SHORTS_DISLIKE_BUTTON.get())
|| (!isDislikesSpan && Settings.HIDE_SHORTS_LIKE_BUTTON.get())) {
return original;
}
ReturnYouTubeDislike videoData = lastLithoShortsVideoData;
if (videoData == null) {
// The Shorts litho video id filter did not detect the video id.
// This is normal in incognito mode, but otherwise is abnormal.
Logger.printDebug(() -> "Cannot modify Shorts litho span, data is null");
return original;
}
// Use the correct dislikes data after voting.
if (lithoShortsShouldUseCurrentData) {
if (isDislikesSpan) {
lithoShortsShouldUseCurrentData = false;
}
videoData = currentVideoData;
if (videoData == null) {
Logger.printException(() -> "currentVideoData is null"); // Should never happen
return original;
}
Logger.printDebug(() -> "Using current video data for litho span");
}
return isDislikesSpan
? videoData.getDislikeSpanForShort((Spanned) original)
: videoData.getLikeSpanForShort((Spanned) original);
}
// //
// Rolling Number // Rolling Number
// //
@ -597,6 +612,7 @@ public class ReturnYouTubeDislikePatch {
Logger.printDebug(() -> "Waiting for prefetch to complete: " + videoId); Logger.printDebug(() -> "Waiting for prefetch to complete: " + videoId);
fetch.getFetchData(20000); // Any arbitrarily large max wait time. fetch.getFetchData(20000); // Any arbitrarily large max wait time.
} }
// Set the fields after the fetch completes, so any concurrent calls will also wait. // Set the fields after the fetch completes, so any concurrent calls will also wait.
lastPlayerResponseWasShort = videoIdIsShort; lastPlayerResponseWasShort = videoIdIsShort;
lastPrefetchedVideoId = videoId; lastPrefetchedVideoId = videoId;
@ -657,6 +673,7 @@ public class ReturnYouTubeDislikePatch {
clearData(); clearData();
return; return;
} }
Logger.printDebug(() -> "New litho Shorts video id: " + videoId); Logger.printDebug(() -> "New litho Shorts video id: " + videoId);
ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId); ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId);
videoData.setVideoIdIsShort(true); videoData.setVideoIdIsShort(true);

View File

@ -52,7 +52,7 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter {
@SuppressWarnings("unused") @SuppressWarnings("unused")
public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) { public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) {
try { try {
if (!isShortAndOpeningOrPlaying || !Settings.RYD_SHORTS.get()) { if (!isShortAndOpeningOrPlaying || !Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) {
return; return;
} }
synchronized (lastVideoIds) { synchronized (lastVideoIds) {
@ -68,21 +68,28 @@ public final class ReturnYouTubeDislikeFilterPatch extends Filter {
private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList(); private final ByteArrayFilterGroupList videoIdFilterGroup = new ByteArrayFilterGroupList();
public ReturnYouTubeDislikeFilterPatch() { public ReturnYouTubeDislikeFilterPatch() {
// Likes always seems to load before the dislikes, but if this
// ever changes then both likes and dislikes need callbacks.
addPathCallbacks( addPathCallbacks(
new StringFilterGroup(Settings.RYD_SHORTS, "|shorts_dislike_button.eml|") new StringFilterGroup(null, "|shorts_like_button.eml")
); );
// After the dislikes icon name is some binary data and then the video id for that specific short.
// After the likes icon name is some binary data and then the video id for that specific short.
videoIdFilterGroup.addAll( videoIdFilterGroup.addAll(
// Video was previously disliked before video was opened. // Video was previously liked before video was opened.
new ByteArrayFilterGroup(null, "ic_right_dislike_on_shadowed"), new ByteArrayFilterGroup(null, "ic_right_like_on_shadowed"),
// Video was not already disliked. // Video was not already liked.
new ByteArrayFilterGroup(null, "ic_right_dislike_off_shadowed") new ByteArrayFilterGroup(null, "ic_right_like_off_shadowed")
); );
} }
@Override @Override
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray, boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) { StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (!Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) {
return false;
}
FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray); FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray);
if (result.isFiltered()) { if (result.isFiltered()) {
String matchedVideoId = findVideoId(protobufBufferArray); String matchedVideoId = findVideoId(protobufBufferArray);

View File

@ -23,6 +23,9 @@ public class Requester {
public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException { public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException {
String url = apiUrl + route.getCompiledRoute(); String url = apiUrl + route.getCompiledRoute();
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
// Request data is in the URL parameters and no body is sent.
// The calling code must set a length if using a request body.
connection.setFixedLengthStreamingMode(0);
connection.setRequestMethod(route.getMethod().name()); connection.setRequestMethod(route.getMethod().name());
String agentString = System.getProperty("http.agent") String agentString = System.getProperty("http.agent")
+ "; ReVanced/" + Utils.getAppVersionName() + "; ReVanced/" + Utils.getAppVersionName()

View File

@ -10,6 +10,9 @@ import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.OvalShape; import android.graphics.drawable.shapes.OvalShape;
import android.graphics.drawable.shapes.RectShape; import android.graphics.drawable.shapes.RectShape;
import android.icu.text.CompactDecimalFormat; import android.icu.text.CompactDecimalFormat;
import android.icu.text.DecimalFormat;
import android.icu.text.DecimalFormatSymbols;
import android.icu.text.NumberFormat;
import android.os.Build; import android.os.Build;
import android.text.Spannable; import android.text.Spannable;
import android.text.SpannableString; import android.text.SpannableString;
@ -25,17 +28,11 @@ import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.text.NumberFormat;
import java.util.HashMap; import java.util.HashMap;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.ExecutionException; import java.util.concurrent.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import app.revanced.integrations.shared.Logger; import app.revanced.integrations.shared.Logger;
import app.revanced.integrations.shared.Utils; import app.revanced.integrations.shared.Utils;
@ -223,32 +220,29 @@ public class ReturnYouTubeDislike {
// Note: Some locales use right to left layout (Arabic, Hebrew, etc). // Note: Some locales use right to left layout (Arabic, Hebrew, etc).
// If making changes to this code, change device settings to a RTL language and verify layout is correct. // If making changes to this code, change device settings to a RTL language and verify layout is correct.
String oldLikesString = oldSpannable.toString(); CharSequence oldLikes = oldSpannable;
// YouTube creators can hide the like count on a video, // YouTube creators can hide the like count on a video,
// and the like count appears as a device language specific string that says 'Like'. // and the like count appears as a device language specific string that says 'Like'.
// Check if the string contains any numbers. // Check if the string contains any numbers.
if (!stringContainsNumber(oldLikesString)) { if (!Utils.containsNumber(oldLikes)) {
// Likes are hidden. // Likes are hidden by video creator
// RYD does not provide usable data for these types of videos, //
// and the API returns bogus data (zero likes and zero dislikes) // RYD does not directly provide like data, but can use an estimated likes
// discussion about this: https://github.com/Anarios/return-youtube-dislike/discussions/530 // using the same scale factor RYD applied to the raw dislikes.
// //
// example video: https://www.youtube.com/watch?v=UnrU5vxCHxw // example video: https://www.youtube.com/watch?v=UnrU5vxCHxw
// RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw // RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw
// //
// Change the "Likes" string to show that likes and dislikes are hidden. Logger.printDebug(() -> "Using estimated likes");
String hiddenMessageString = str("revanced_ryd_video_likes_hidden_by_video_owner"); oldLikes = formatDislikeCount(voteData.getLikeCount());
return newSpanUsingStylingOfAnotherSpan(oldSpannable, hiddenMessageString);
} }
SpannableStringBuilder builder = new SpannableStringBuilder(); SpannableStringBuilder builder = new SpannableStringBuilder();
final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get(); final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get();
if (!compactLayout) { if (!compactLayout) {
String leftSeparatorString = Utils.isRightToLeftTextLayout() String leftSeparatorString = getTextDirectionString();
? "\u200F" // u200F = right to left character
: "\u200E"; // u200E = left to right character
final Spannable leftSeparatorSpan; final Spannable leftSeparatorSpan;
if (isRollingNumber) { if (isRollingNumber) {
leftSeparatorSpan = new SpannableString(leftSeparatorString); leftSeparatorSpan = new SpannableString(leftSeparatorString);
@ -267,7 +261,7 @@ public class ReturnYouTubeDislike {
} }
// likes // likes
builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikesString)); builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikes));
// middle separator // middle separator
String middleSeparatorString = compactLayout String middleSeparatorString = compactLayout
@ -292,6 +286,12 @@ public class ReturnYouTubeDislike {
return new SpannableString(builder); return new SpannableString(builder);
} }
private static @NonNull String getTextDirectionString() {
return Utils.isRightToLeftTextLayout()
? "\u200F" // u200F = right to left character
: "\u200E"; // u200E = left to right character
}
/** /**
* @return If the text is likely for a previously created likes/dislikes segmented span. * @return If the text is likely for a previously created likes/dislikes segmented span.
*/ */
@ -299,20 +299,6 @@ public class ReturnYouTubeDislike {
return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0; return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0;
} }
/**
* Correctly handles any unicode numbers (such as Arabic numbers).
*
* @return if the string contains at least 1 number.
*/
private static boolean stringContainsNumber(@NonNull String text) {
for (int index = 0, length = text.length(); index < length; index++) {
if (Character.isDigit(text.codePointAt(index))) {
return true;
}
}
return false;
}
private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) { private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) {
// Cannot use equals on the span, because many of the inner styling spans do not implement equals. // Cannot use equals on the span, because many of the inner styling spans do not implement equals.
// Instead, compare the underlying text and the text color to handle when dark mode is changed. // Instead, compare the underlying text and the text color to handle when dark mode is changed.
@ -334,6 +320,10 @@ public class ReturnYouTubeDislike {
return true; return true;
} }
private static SpannableString newSpannableWithLikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) {
return newSpanUsingStylingOfAnotherSpan(sourceStyling, formatDislikeCount(voteData.getLikeCount()));
}
private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) { private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) {
return newSpanUsingStylingOfAnotherSpan(sourceStyling, return newSpanUsingStylingOfAnotherSpan(sourceStyling,
Settings.RYD_DISLIKE_PERCENTAGE.get() Settings.RYD_DISLIKE_PERCENTAGE.get()
@ -342,11 +332,16 @@ public class ReturnYouTubeDislike {
} }
private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) { private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) {
if (sourceStyle == newSpanText && sourceStyle instanceof SpannableString) {
return (SpannableString) sourceStyle; // Nothing to do.
}
SpannableString destination = new SpannableString(newSpanText); SpannableString destination = new SpannableString(newSpanText);
Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class); Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class);
for (Object span : spans) { for (Object span : spans) {
destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span)); destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span));
} }
return destination; return destination;
} }
@ -354,13 +349,18 @@ public class ReturnYouTubeDislike {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
if (dislikeCountFormatter == null) { if (dislikeCountFormatter == null) {
// Note: Java number formatters will use the locale specific number characters.
// such as Arabic which formats "1.234" into "۱,۲۳٤"
// But YouTube disregards locale specific number characters
// and instead shows english number characters everywhere.
Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale;
Logger.printDebug(() -> "Locale: " + locale);
dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT); dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT);
// YouTube disregards locale specific number characters
// and instead shows english number characters everywhere.
// To use the same behavior, override the digit characters to use English
// so languages such as Arabic will show "1.234" instead of the native "۱,۲۳٤"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings());
dislikeCountFormatter.setDecimalFormatSymbols(symbols);
}
} }
return dislikeCountFormatter.format(dislikeCount); return dislikeCountFormatter.format(dislikeCount);
} }
@ -371,19 +371,31 @@ public class ReturnYouTubeDislike {
} }
private static String formatDislikePercentage(float dislikePercentage) { private static String formatDislikePercentage(float dislikePercentage) {
synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (dislikePercentageFormatter == null) { synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale; if (dislikePercentageFormatter == null) {
Logger.printDebug(() -> "Locale: " + locale); Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale;
dislikePercentageFormatter = NumberFormat.getPercentInstance(locale); dislikePercentageFormatter = NumberFormat.getPercentInstance(locale);
// Want to set the digit strings, and the simplest way is to cast to the implementation NumberFormat returns.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
&& dislikePercentageFormatter instanceof DecimalFormat) {
DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings());
((DecimalFormat) dislikePercentageFormatter).setDecimalFormatSymbols(symbols);
}
}
if (dislikePercentage >= 0.01) { // at least 1%
dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points
} else {
dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision
}
return dislikePercentageFormatter.format(dislikePercentage);
} }
if (dislikePercentage >= 0.01) { // at least 1%
dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points
} else {
dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision
}
return dislikePercentageFormatter.format(dislikePercentage);
} }
// Will never be reached, as the oldest supported YouTube app requires Android N or greater.
return String.valueOf((int) (dislikePercentage * 100));
} }
@NonNull @NonNull
@ -484,7 +496,17 @@ public class ReturnYouTubeDislike {
public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original, public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original,
boolean isSegmentedButton, boolean isSegmentedButton,
boolean isRollingNumber) { boolean isRollingNumber) {
return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton, isRollingNumber,false); return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton,
isRollingNumber, false, false);
}
/**
* Called when a Shorts like Spannable is created.
*/
@NonNull
public synchronized Spanned getLikeSpanForShort(@NonNull Spanned original) {
return waitForFetchAndUpdateReplacementSpan(original, false,
false, true, true);
} }
/** /**
@ -492,14 +514,16 @@ public class ReturnYouTubeDislike {
*/ */
@NonNull @NonNull
public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) { public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) {
return waitForFetchAndUpdateReplacementSpan(original, false, false, true); return waitForFetchAndUpdateReplacementSpan(original, false,
false, true, false);
} }
@NonNull @NonNull
private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original, private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original,
boolean isSegmentedButton, boolean isSegmentedButton,
boolean isRollingNumber, boolean isRollingNumber,
boolean spanIsForShort) { boolean spanIsForShort,
boolean spanIsForLikes) {
try { try {
RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH); RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
if (votingData == null) { if (votingData == null) {
@ -526,24 +550,17 @@ public class ReturnYouTubeDislike {
return original; return original;
} }
if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) { if (spanIsForLikes) {
if (spansHaveEqualTextAndColor(original, replacementLikeDislikeSpan)) { // Scrolling Shorts does not cause the Spans to be reloaded,
Logger.printDebug(() -> "Ignoring previously created dislikes span of data: " + videoId); // so there is no need to cache the likes for this situations.
return original; Logger.printDebug(() -> "Creating likes span for: " + votingData.videoId);
} return newSpannableWithLikes(original, votingData);
if (spansHaveEqualTextAndColor(original, originalDislikeSpan)) {
Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId);
return replacementLikeDislikeSpan;
}
} }
if (isSegmentedButton && isPreviouslyCreatedSegmentedSpan(original.toString())) {
// need to recreate using original, as original has prior outdated dislike values if (originalDislikeSpan != null && replacementLikeDislikeSpan != null
if (originalDislikeSpan == null) { && spansHaveEqualTextAndColor(original, originalDislikeSpan)) {
// Should never happen. Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId);
Logger.printDebug(() -> "Cannot add dislikes - original span is null. videoId: " + videoId); return replacementLikeDislikeSpan;
return original;
}
original = originalDislikeSpan;
} }
// No replacement span exist, create it now. // No replacement span exist, create it now.
@ -558,9 +575,10 @@ public class ReturnYouTubeDislike {
return replacementLikeDislikeSpan; return replacementLikeDislikeSpan;
} }
} catch (Exception e) { } catch (Exception ex) {
Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", ex);
} }
return original; return original;
} }

View File

@ -3,10 +3,13 @@ package app.revanced.integrations.youtube.returnyoutubedislike.requests;
import static app.revanced.integrations.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote; import static app.revanced.integrations.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import app.revanced.integrations.shared.Logger;
/** /**
* ReturnYouTubeDislike API estimated like/dislike/view counts. * ReturnYouTubeDislike API estimated like/dislike/view counts.
* *
@ -23,38 +26,65 @@ public final class RYDVoteData {
public final long viewCount; public final long viewCount;
private final long fetchedLikeCount; private final long fetchedLikeCount;
private volatile long likeCount; // read/write from different threads private volatile long likeCount; // Read/write from different threads.
/**
* Like count can be hidden by video creator, but RYD still tracks the number
* of like/dislikes it received thru it's browser extension and and API.
* The raw like/dislikes can be used to calculate a percentage.
*
* Raw values can be null, especially for older videos with little to no views.
*/
@Nullable
private final Long fetchedRawLikeCount;
private volatile float likePercentage; private volatile float likePercentage;
private final long fetchedDislikeCount; private final long fetchedDislikeCount;
private volatile long dislikeCount; // read/write from different threads private volatile long dislikeCount; // Read/write from different threads.
@Nullable
private final Long fetchedRawDislikeCount;
private volatile float dislikePercentage; private volatile float dislikePercentage;
@Nullable
private static Long getLongIfExist(JSONObject json, String key) throws JSONException {
return json.isNull(key)
? null
: json.getLong(key);
}
/** /**
* @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values) * @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values)
*/ */
public RYDVoteData(@NonNull JSONObject json) throws JSONException { public RYDVoteData(@NonNull JSONObject json) throws JSONException {
videoId = json.getString("id"); videoId = json.getString("id");
viewCount = json.getLong("viewCount"); viewCount = json.getLong("viewCount");
fetchedLikeCount = json.getLong("likes"); fetchedLikeCount = json.getLong("likes");
fetchedRawLikeCount = getLongIfExist(json, "rawLikes");
fetchedDislikeCount = json.getLong("dislikes"); fetchedDislikeCount = json.getLong("dislikes");
fetchedRawDislikeCount = getLongIfExist(json, "rawDislikes");
if (viewCount < 0 || fetchedLikeCount < 0 || fetchedDislikeCount < 0) { if (viewCount < 0 || fetchedLikeCount < 0 || fetchedDislikeCount < 0) {
throw new JSONException("Unexpected JSON values: " + json); throw new JSONException("Unexpected JSON values: " + json);
} }
likeCount = fetchedLikeCount; likeCount = fetchedLikeCount;
dislikeCount = fetchedDislikeCount; dislikeCount = fetchedDislikeCount;
updatePercentages();
updateUsingVote(Vote.LIKE_REMOVE); // Calculate percentages.
} }
/** /**
* Estimated like count * Public like count of the video, as reported by YT when RYD last updated it's data.
*
* If the likes were hidden by the video creator, then this returns an
* estimated likes using the same extrapolation as the dislikes.
*/ */
public long getLikeCount() { public long getLikeCount() {
return likeCount; return likeCount;
} }
/** /**
* Estimated dislike count * Estimated total dislike count, extrapolated from the public like count using RYD data.
*/ */
public long getDislikeCount() { public long getDislikeCount() {
return dislikeCount; return dislikeCount;
@ -79,28 +109,56 @@ public final class RYDVoteData {
} }
public void updateUsingVote(Vote vote) { public void updateUsingVote(Vote vote) {
final int likesToAdd, dislikesToAdd;
switch (vote) { switch (vote) {
case LIKE: case LIKE:
likeCount = fetchedLikeCount + 1; likesToAdd = 1;
dislikeCount = fetchedDislikeCount; dislikesToAdd = 0;
break; break;
case DISLIKE: case DISLIKE:
likeCount = fetchedLikeCount; likesToAdd = 0;
dislikeCount = fetchedDislikeCount + 1; dislikesToAdd = 1;
break; break;
case LIKE_REMOVE: case LIKE_REMOVE:
likeCount = fetchedLikeCount; likesToAdd = 0;
dislikeCount = fetchedDislikeCount; dislikesToAdd = 0;
break; break;
default: default:
throw new IllegalStateException(); throw new IllegalStateException();
} }
updatePercentages();
}
private void updatePercentages() { // If a video has no public likes but RYD has raw like data,
likePercentage = (likeCount == 0 ? 0 : (float) likeCount / (likeCount + dislikeCount)); // then use the raw data instead.
dislikePercentage = (dislikeCount == 0 ? 0 : (float) dislikeCount / (likeCount + dislikeCount)); final boolean videoHasNoPublicLikes = fetchedLikeCount == 0;
final boolean hasRawData = fetchedRawLikeCount != null && fetchedRawDislikeCount != null;
if (videoHasNoPublicLikes && hasRawData && fetchedRawDislikeCount > 0) {
// YT creator has hidden the likes count, and this is an older video that
// RYD does not provide estimated like counts.
//
// But we can calculate the public likes the same way RYD does for newer videos with hidden likes,
// by using the same raw to estimated scale factor applied to dislikes.
// This calculation exactly matches the public likes RYD provides for newer hidden videos.
final float estimatedRawScaleFactor = (float) fetchedDislikeCount / fetchedRawDislikeCount;
likeCount = (long) (estimatedRawScaleFactor * fetchedRawLikeCount) + likesToAdd;
Logger.printDebug(() -> "Using locally calculated estimated likes since RYD did not return an estimate");
} else {
likeCount = fetchedLikeCount + likesToAdd;
}
// RYD now always returns an estimated dislike count, even if the likes are hidden.
dislikeCount = fetchedDislikeCount + dislikesToAdd;
// Update percentages.
final float totalCount = likeCount + dislikeCount;
if (totalCount == 0) {
likePercentage = 0;
dislikePercentage = 0;
} else {
likePercentage = likeCount / totalCount;
dislikePercentage = dislikeCount / totalCount;
}
} }
@NonNull @NonNull

View File

@ -197,7 +197,7 @@ public class ReturnYouTubeDislikeApi {
return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT; return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT;
} }
@SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are estimates. @SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are only estimates.
private static void updateRateLimitAndStats(long timeNetworkCallStarted, boolean connectionError, boolean rateLimitHit) { private static void updateRateLimitAndStats(long timeNetworkCallStarted, boolean connectionError, boolean rateLimitHit) {
if (connectionError && rateLimitHit) { if (connectionError && rateLimitHit) {
throw new IllegalArgumentException(); throw new IllegalArgumentException();
@ -368,10 +368,12 @@ public class ReturnYouTubeDislikeApi {
applyCommonPostRequestSettings(connection); applyCommonPostRequestSettings(connection);
String jsonInputString = "{\"solution\": \"" + solution + "\"}"; String jsonInputString = "{\"solution\": \"" + solution + "\"}";
byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(body.length);
try (OutputStream os = connection.getOutputStream()) { try (OutputStream os = connection.getOutputStream()) {
byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); os.write(body);
os.write(input, 0, input.length);
} }
final int responseCode = connection.getResponseCode(); final int responseCode = connection.getResponseCode();
if (checkIfRateLimitWasHit(responseCode)) { if (checkIfRateLimitWasHit(responseCode)) {
connection.disconnect(); // disconnect, as no more connections will be made for a little while connection.disconnect(); // disconnect, as no more connections will be made for a little while
@ -440,9 +442,10 @@ public class ReturnYouTubeDislikeApi {
applyCommonPostRequestSettings(connection); applyCommonPostRequestSettings(connection);
String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}"; String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}";
byte[] body = voteJsonString.getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(body.length);
try (OutputStream os = connection.getOutputStream()) { try (OutputStream os = connection.getOutputStream()) {
byte[] input = voteJsonString.getBytes(StandardCharsets.UTF_8); os.write(body);
os.write(input, 0, input.length);
} }
final int responseCode = connection.getResponseCode(); final int responseCode = connection.getResponseCode();
@ -490,10 +493,12 @@ public class ReturnYouTubeDislikeApi {
applyCommonPostRequestSettings(connection); applyCommonPostRequestSettings(connection);
String jsonInputString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"solution\": \"" + solution + "\"}"; String jsonInputString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"solution\": \"" + solution + "\"}";
byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(body.length);
try (OutputStream os = connection.getOutputStream()) { try (OutputStream os = connection.getOutputStream()) {
byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); os.write(body);
os.write(input, 0, input.length);
} }
final int responseCode = connection.getResponseCode(); final int responseCode = connection.getResponseCode();
if (checkIfRateLimitWasHit(responseCode)) { if (checkIfRateLimitWasHit(responseCode)) {
connection.disconnect(); // disconnect, as no more connections will be made for a little while connection.disconnect(); // disconnect, as no more connections will be made for a little while