revanced-integrations/app/src/main/java/app/revanced/integrations/youtube/sponsorblock/requests/SBRequester.java

316 lines
15 KiB
Java

package app.revanced.integrations.youtube.sponsorblock.requests;
import static app.revanced.integrations.shared.StringRef.str;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import app.revanced.integrations.youtube.requests.Requester;
import app.revanced.integrations.youtube.requests.Route;
import app.revanced.integrations.youtube.settings.Settings;
import app.revanced.integrations.youtube.sponsorblock.SponsorBlockSettings;
import app.revanced.integrations.youtube.sponsorblock.objects.SegmentCategory;
import app.revanced.integrations.youtube.sponsorblock.objects.SponsorSegment;
import app.revanced.integrations.youtube.sponsorblock.objects.SponsorSegment.SegmentVote;
import app.revanced.integrations.youtube.sponsorblock.objects.UserStats;
import app.revanced.integrations.shared.Logger;
import app.revanced.integrations.shared.Utils;
public class SBRequester {
private static final String TIME_TEMPLATE = "%.3f";
/**
* TCP timeout
*/
private static final int TIMEOUT_TCP_DEFAULT_MILLISECONDS = 7000;
/**
* HTTP response timeout
*/
private static final int TIMEOUT_HTTP_DEFAULT_MILLISECONDS = 10000;
/**
* Response code of a successful API call
*/
private static final int HTTP_STATUS_CODE_SUCCESS = 200;
private SBRequester() {
}
private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex) {
if (Settings.SB_TOAST_ON_CONNECTION_ERROR.get()) {
Utils.showToastShort(toastMessage);
}
if (ex != null) {
Logger.printInfo(() -> toastMessage, ex);
}
}
@NonNull
public static SponsorSegment[] getSegments(@NonNull String videoId) {
Utils.verifyOffMainThread();
List<SponsorSegment> segments = new ArrayList<>();
try {
HttpURLConnection connection = getConnectionFromRoute(SBRoutes.GET_SEGMENTS, videoId, SegmentCategory.sponsorBlockAPIFetchCategories);
final int responseCode = connection.getResponseCode();
if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
JSONArray responseArray = Requester.parseJSONArray(connection);
final long minSegmentDuration = (long) (Settings.SB_SEGMENT_MIN_DURATION.get() * 1000);
for (int i = 0, length = responseArray.length(); i < length; i++) {
JSONObject obj = (JSONObject) responseArray.get(i);
JSONArray segment = obj.getJSONArray("segment");
final long start = (long) (segment.getDouble(0) * 1000);
final long end = (long) (segment.getDouble(1) * 1000);
String uuid = obj.getString("UUID");
final boolean locked = obj.getInt("locked") == 1;
String categoryKey = obj.getString("category");
SegmentCategory category = SegmentCategory.byCategoryKey(categoryKey);
if (category == null) {
Logger.printException(() -> "Received unknown category: " + categoryKey); // should never happen
} else if ((end - start) >= minSegmentDuration || category == SegmentCategory.HIGHLIGHT) {
segments.add(new SponsorSegment(category, uuid, start, end, locked));
}
}
Logger.printDebug(() -> {
StringBuilder builder = new StringBuilder("Downloaded segments:");
for (SponsorSegment segment : segments) {
builder.append('\n').append(segment);
}
return builder.toString();
});
runVipCheckInBackgroundIfNeeded();
} else if (responseCode == 404) {
// no segments are found. a normal response
Logger.printDebug(() -> "No segments found for video: " + videoId);
} else {
handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_status", responseCode), null);
connection.disconnect(); // something went wrong, might as well disconnect
}
} catch (SocketTimeoutException ex) {
handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_timeout"), ex);
} catch (IOException ex) {
handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_generic"), ex);
} catch (Exception ex) {
// Should never happen
Logger.printException(() -> "getSegments failure", ex);
}
// Crude debug tests to verify random features
// Could benefit from:
// 1) collection of YouTube videos with test segment times (verify client skip timing matches the video, verify seekbar draws correctly)
// 2) unit tests (verify everything else)
if (false) {
segments.clear();
// Test auto-hide skip button:
// Button should appear only once
segments.add(new SponsorSegment(SegmentCategory.INTRO, "debug", 5000, 120000, false));
// Button should appear only once
segments.add(new SponsorSegment(SegmentCategory.SELF_PROMO, "debug", 10000, 60000, false));
// Button should appear only once
segments.add(new SponsorSegment(SegmentCategory.INTERACTION, "debug", 15000, 20000, false));
// Button should appear _twice_ (at 21s and 27s)
segments.add(new SponsorSegment(SegmentCategory.SPONSOR, "debug", 21000, 30000, false));
// Button should appear only once
segments.add(new SponsorSegment(SegmentCategory.OUTRO, "debug", 24000, 27000, false));
// Test seekbar visibility:
// All three segments should be viewable on the seekbar
segments.add(new SponsorSegment(SegmentCategory.MUSIC_OFFTOPIC, "debug", 200000, 300000, false));
segments.add(new SponsorSegment(SegmentCategory.SPONSOR, "debug", 200000, 250000, false));
segments.add(new SponsorSegment(SegmentCategory.SELF_PROMO, "debug", 200000, 330000, false));
}
return segments.toArray(new SponsorSegment[0]);
}
public static void submitSegments(@NonNull String videoId, @NonNull String category,
long startTime, long endTime, long videoLength) {
Utils.verifyOffMainThread();
try {
String privateUserId = SponsorBlockSettings.getSBPrivateUserID();
String start = String.format(Locale.US, TIME_TEMPLATE, startTime / 1000f);
String end = String.format(Locale.US, TIME_TEMPLATE, endTime / 1000f);
String duration = String.format(Locale.US, TIME_TEMPLATE, videoLength / 1000f);
HttpURLConnection connection = getConnectionFromRoute(SBRoutes.SUBMIT_SEGMENTS, privateUserId, videoId, category, start, end, duration);
final int responseCode = connection.getResponseCode();
final String messageToToast;
switch (responseCode) {
case HTTP_STATUS_CODE_SUCCESS:
messageToToast = str("revanced_sb_submit_succeeded");
break;
case 409:
messageToToast = str("revanced_sb_submit_failed_duplicate");
break;
case 403:
messageToToast = str("revanced_sb_submit_failed_forbidden", Requester.parseErrorStringAndDisconnect(connection));
break;
case 429:
messageToToast = str("revanced_sb_submit_failed_rate_limit");
break;
case 400:
messageToToast = str("revanced_sb_submit_failed_invalid", Requester.parseErrorStringAndDisconnect(connection));
break;
default:
messageToToast = str("revanced_sb_submit_failed_unknown_error", responseCode, connection.getResponseMessage());
break;
}
Utils.showToastLong(messageToToast);
} catch (SocketTimeoutException ex) {
// Always show, even if show connection toasts is turned off
Utils.showToastLong(str("revanced_sb_submit_failed_timeout"));
} catch (IOException ex) {
Utils.showToastLong(str("revanced_sb_submit_failed_unknown_error", 0, ex.getMessage()));
} catch (Exception ex) {
Logger.printException(() -> "failed to submit segments", ex);
}
}
public static void sendSegmentSkippedViewedRequest(@NonNull SponsorSegment segment) {
Utils.verifyOffMainThread();
try {
HttpURLConnection connection = getConnectionFromRoute(SBRoutes.VIEWED_SEGMENT, segment.UUID);
final int responseCode = connection.getResponseCode();
if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
Logger.printDebug(() -> "Successfully sent view count for segment: " + segment);
} else {
Logger.printDebug(() -> "Failed to sent view count for segment: " + segment.UUID
+ " responseCode: " + responseCode); // debug level, no toast is shown
}
} catch (IOException ex) {
Logger.printInfo(() -> "Failed to send view count", ex); // do not show a toast
} catch (Exception ex) {
Logger.printException(() -> "Failed to send view count request", ex); // should never happen
}
}
public static void voteForSegmentOnBackgroundThread(@NonNull SponsorSegment segment, @NonNull SegmentVote voteOption) {
voteOrRequestCategoryChange(segment, voteOption, null);
}
public static void voteToChangeCategoryOnBackgroundThread(@NonNull SponsorSegment segment, @NonNull SegmentCategory categoryToVoteFor) {
voteOrRequestCategoryChange(segment, SegmentVote.CATEGORY_CHANGE, categoryToVoteFor);
}
private static void voteOrRequestCategoryChange(@NonNull SponsorSegment segment, @NonNull SegmentVote voteOption, SegmentCategory categoryToVoteFor) {
Utils.runOnBackgroundThread(() -> {
try {
String segmentUuid = segment.UUID;
String uuid = SponsorBlockSettings.getSBPrivateUserID();
HttpURLConnection connection = (voteOption == SegmentVote.CATEGORY_CHANGE)
? getConnectionFromRoute(SBRoutes.VOTE_ON_SEGMENT_CATEGORY, uuid, segmentUuid, categoryToVoteFor.keyValue)
: getConnectionFromRoute(SBRoutes.VOTE_ON_SEGMENT_QUALITY, uuid, segmentUuid, String.valueOf(voteOption.apiVoteType));
final int responseCode = connection.getResponseCode();
switch (responseCode) {
case HTTP_STATUS_CODE_SUCCESS:
Logger.printDebug(() -> "Vote success for segment: " + segment);
break;
case 403:
Utils.showToastLong(
str("revanced_sb_vote_failed_forbidden", Requester.parseErrorStringAndDisconnect(connection)));
break;
default:
Utils.showToastLong(
str("revanced_sb_vote_failed_unknown_error", responseCode, connection.getResponseMessage()));
break;
}
} catch (SocketTimeoutException ex) {
Utils.showToastShort(str("revanced_sb_vote_failed_timeout"));
} catch (IOException ex) {
Utils.showToastShort(str("revanced_sb_vote_failed_unknown_error", 0, ex.getMessage()));
} catch (Exception ex) {
Logger.printException(() -> "failed to vote for segment", ex); // should never happen
}
});
}
/**
* @return NULL, if stats fetch failed
*/
@Nullable
public static UserStats retrieveUserStats() {
Utils.verifyOffMainThread();
try {
UserStats stats = new UserStats(getJSONObject(SBRoutes.GET_USER_STATS, SponsorBlockSettings.getSBPrivateUserID()));
Logger.printDebug(() -> "user stats: " + stats);
return stats;
} catch (IOException ex) {
Logger.printInfo(() -> "failed to retrieve user stats", ex); // info level, do not show a toast
} catch (Exception ex) {
Logger.printException(() -> "failure retrieving user stats", ex); // should never happen
}
return null;
}
/**
* @return NULL if the call was successful. If unsuccessful, an error message is returned.
*/
@Nullable
public static String setUsername(@NonNull String username) {
Utils.verifyOffMainThread();
try {
HttpURLConnection connection = getConnectionFromRoute(SBRoutes.CHANGE_USERNAME, SponsorBlockSettings.getSBPrivateUserID(), username);
final int responseCode = connection.getResponseCode();
String responseMessage = connection.getResponseMessage();
if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
return null;
}
return str("revanced_sb_stats_username_change_unknown_error", responseCode, responseMessage);
} catch (Exception ex) { // should never happen
Logger.printInfo(() -> "failed to set username", ex); // do not toast
return str("revanced_sb_stats_username_change_unknown_error", 0, ex.getMessage());
}
}
public static void runVipCheckInBackgroundIfNeeded() {
if (!SponsorBlockSettings.userHasSBPrivateId()) {
return; // User cannot be a VIP. User has never voted, created any segments, or has imported a SB user id.
}
long now = System.currentTimeMillis();
if (now < (Settings.SB_LAST_VIP_CHECK.get() + TimeUnit.DAYS.toMillis(3))) {
return;
}
Utils.runOnBackgroundThread(() -> {
try {
JSONObject json = getJSONObject(SBRoutes.IS_USER_VIP, SponsorBlockSettings.getSBPrivateUserID());
boolean vip = json.getBoolean("vip");
Settings.SB_USER_IS_VIP.save(vip);
Settings.SB_LAST_VIP_CHECK.save(now);
} catch (IOException ex) {
Logger.printInfo(() -> "Failed to check VIP (network error)", ex); // info, so no error toast is shown
} catch (Exception ex) {
Logger.printException(() -> "Failed to check VIP", ex); // should never happen
}
});
}
// helpers
private static HttpURLConnection getConnectionFromRoute(@NonNull Route route, String... params) throws IOException {
HttpURLConnection connection = Requester.getConnectionFromRoute(Settings.SB_API_URL.get(), route, params);
connection.setConnectTimeout(TIMEOUT_TCP_DEFAULT_MILLISECONDS);
connection.setReadTimeout(TIMEOUT_HTTP_DEFAULT_MILLISECONDS);
return connection;
}
private static JSONObject getJSONObject(@NonNull Route route, String... params) throws IOException, JSONException {
return Requester.parseJSONObject(getConnectionFromRoute(route, params));
}
}