From e3f00f43e6a768890a52a4ce918f75b211ba6e56 Mon Sep 17 00:00:00 2001 From: devoxin <15076404+devoxin@users.noreply.github.com> Date: Tue, 20 Aug 2024 00:19:24 +0100 Subject: [PATCH] Support OAuth2 Account Integration (#33) * OAuth2 integration part 1 * implement token refreshing * cleanups, usability, etc. * Update log message, use Android.BASE_CONFIG for visitor ID fetching * Reapply context filter. * Don't apply token/UA/visitor ID to googlevideo URLs * Allow skipping OAuth initialisation on empty tokens * Fix incorrect number of arguments in YoutubeRestHandler * Correctly extract interval, handle slow_down and access_denied * Note usage of #getContextFilter with rotator. * Slight refactor to oauth token handling and refreshing. * README updates * rename oauthConfig -> oauth * Some minor changes. * Remove todo and clear access token when setting new refresh token --- README.md | 51 ++- .../youtube/YoutubeAudioSourceManager.java | 45 ++- .../http/YoutubeAccessTokenTracker.java | 11 +- .../http/YoutubeHttpContextFilter.java | 25 +- .../youtube/http/YoutubeOauth2Handler.java | 296 ++++++++++++++++++ .../youtube/plugin/YoutubeConfig.java | 9 + .../youtube/plugin/YoutubeOauthConfig.java | 31 ++ .../youtube/plugin/YoutubePluginLoader.java | 11 +- .../youtube/plugin/YoutubeRestHandler.java | 51 +++ 9 files changed, 509 insertions(+), 21 deletions(-) create mode 100644 common/src/main/java/dev/lavalink/youtube/http/YoutubeOauth2Handler.java create mode 100644 plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeOauthConfig.java create mode 100644 plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeRestHandler.java diff --git a/README.md b/README.md index 79b4ed2..aa9cd0e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Which clients are used is entirely configurable. - Information about the `plugin` module and usage of. - [Available Clients](#available-clients) - Information about the clients provided by `youtube-source`, as well as their advantages/disadvantages. +- [Using OAuth tokens](#using-oauth-tokens) + - Information on using OAuth tokens with `youtube-source`. - [Using a poToken](#using-a-potoken) - Information on using a `poToken` with `youtube-source`. - [Migration Information](#migration-from-lavaplayers-built-in-youtube-source) @@ -58,8 +60,9 @@ Support for IP rotation has been included, and can be achieved using the followi AbstractRoutePlanner routePlanner = new ... YoutubeIpRotatorSetup rotator = new YoutubeIpRotatorSetup(routePlanner); +// 'youtube' is the variable holding your YoutubeAudioSourceManager instance. rotator.forConfiguration(youtube.getHttpInterfaceManager(), false) - .withMainDelegateFilter(null) // This is important, otherwise you may get NullPointerExceptions. + .withMainDelegateFilter(youtube.getContextFilter()) // IMPORTANT .setup(); ``` @@ -206,6 +209,52 @@ Currently, the following clients are available for use: - ✔ Age-restricted video playback. - ❌ No playlist support. +## Using OAuth Tokens +You may notice that some requests are flagged by YouTube, causing an error message asking you to sign in to confirm you're not a bot. +With OAuth integration, you can request that `youtube-source` use your account credentials to appear as a normal user, with varying degrees +of efficacy. You can instruct `youtube-source` to use OAuth with the following: + +> [!WARNING] +> Similar to the `poToken` method, this is NOT a silver bullet solution, and worst case could get your account terminated! +> For this reason, it is advised that **you use burner accounts and NOT your primary!**. +> This method may also trigger ratelimit errors if used in a high traffic environment. +> USE WITH CAUTION! + +### Lavaplayer +```java +YoutubeAudioSourceManager source = new YoutubeAudioSourceManager(); +// This will trigger an OAuth flow, where you will be instructed to head to YouTube's OAuth page and input a code. +// This is safe, as it only uses YouTube's official OAuth flow. No tokens are seen or stored by us. +source.useOauth2(null, false); + +// If you already have a refresh token, you can instruct the source to use it, skipping the OAuth flow entirely. +// You can also set the `skipInitialization` parameter, which skips the OAuth flow. This should only be used +// if you intend to supply a refresh token later on. You **must** either complete the OAuth flow or supply +// a refresh token for OAuth integration to work. +source.useOauth2("your refresh token", true); +``` + + + +### Lavalink +```yaml +plugins: + youtube: + enabled: true + oauth: + # setting "enabled: true" is the bare minimum to get OAuth working. + enabled: true + + # you may optionally set your refresh token if you have one, which skips the OAuth flow entirely. + # once you have completed the oauth flow at least once, you should see your refresh token within your + # lavalink logs, which can be used here. + refreshToken: "your refresh token, only supply this if you have one!" + + # Set this if you don't want the OAuth flow to be triggered, if you intend to supply a refresh token + # later on via REST routes. Initialization is skipped automatically if a valid refresh token is supplied. + skipInitialization: true +``` + ## Using a `poToken` A `poToken`, also known as a "Proof of Origin Token" is a way to identify what requests originate from. In YouTube's case, this is sent as a JavaScript challenge that browsers must evaluate, and send back the resolved diff --git a/common/src/main/java/dev/lavalink/youtube/YoutubeAudioSourceManager.java b/common/src/main/java/dev/lavalink/youtube/YoutubeAudioSourceManager.java index 0eaadda..2648de1 100644 --- a/common/src/main/java/dev/lavalink/youtube/YoutubeAudioSourceManager.java +++ b/common/src/main/java/dev/lavalink/youtube/YoutubeAudioSourceManager.java @@ -18,6 +18,7 @@ import dev.lavalink.youtube.clients.skeleton.Client; import dev.lavalink.youtube.http.YoutubeAccessTokenTracker; import dev.lavalink.youtube.http.YoutubeHttpContextFilter; +import dev.lavalink.youtube.http.YoutubeOauth2Handler; import dev.lavalink.youtube.track.YoutubeAudioTrack; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; @@ -58,12 +59,15 @@ public class YoutubeAudioSourceManager implements AudioSourceManager { private static final Pattern shortHandPattern = Pattern.compile("^" + PROTOCOL_REGEX + "(?:" + DOMAIN_REGEX + "/(?:live|embed|shorts)|" + SHORT_DOMAIN_REGEX + ")/(?.*)"); protected final HttpInterfaceManager httpInterfaceManager; + protected final boolean allowSearch; protected final boolean allowDirectVideoIds; protected final boolean allowDirectPlaylistIds; protected final Client[] clients; - protected final SignatureCipherManager cipherManager; + protected YoutubeHttpContextFilter contextFilter; + protected YoutubeOauth2Handler oauth2Handler; + protected SignatureCipherManager cipherManager; public YoutubeAudioSourceManager() { this(true); @@ -133,11 +137,13 @@ public YoutubeAudioSourceManager(YoutubeSourceOptions options, this.allowDirectPlaylistIds = options.isAllowDirectPlaylistIds(); this.clients = clients; this.cipherManager = new SignatureCipherManager(); + this.oauth2Handler = new YoutubeOauth2Handler(httpInterfaceManager); + + contextFilter = new YoutubeHttpContextFilter(); + contextFilter.setTokenTracker(new YoutubeAccessTokenTracker(httpInterfaceManager)); + contextFilter.setOauth2Handler(oauth2Handler); - YoutubeAccessTokenTracker tokenTracker = new YoutubeAccessTokenTracker(httpInterfaceManager); - YoutubeHttpContextFilter youtubeHttpContextFilter = new YoutubeHttpContextFilter(); - youtubeHttpContextFilter.setTokenTracker(tokenTracker); - httpInterfaceManager.setHttpContextFilter(youtubeHttpContextFilter); + httpInterfaceManager.setHttpContextFilter(contextFilter); } @Override @@ -151,6 +157,25 @@ public void setPlaylistPageCount(int count) { } } + /** + * Instructs this source to use Oauth2 integration. + * {@code null} is valid and will kickstart the oauth process. + * Providing a refresh token will likely skip having to authenticate your account prior to making requests, + * as long as the provided token is still valid. + * @param refreshToken The token to use for generating access tokens. Can be null. + * @param skipInitialization Whether linking of an account should be skipped, if you intend to provide a + * refresh token later. This only applies on null/empty/invalid refresh tokens. + * Valid refresh tokens will not be presented with an initialization prompt. + */ + public void useOauth2(@Nullable String refreshToken, boolean skipInitialization) { + oauth2Handler.setRefreshToken(refreshToken, skipInitialization); + } + + @Nullable + public String getOauth2RefreshToken() { + return oauth2Handler.getRefreshToken(); + } + @Override @Nullable public AudioItem loadItem(@NotNull AudioPlayerManager manager, @NotNull AudioReference reference) { @@ -348,6 +373,16 @@ public Client[] getClients() { return clients; } + @NotNull + public YoutubeHttpContextFilter getContextFilter() { + return contextFilter; + } + + @NotNull + public YoutubeOauth2Handler getOauth2Handler() { + return oauth2Handler; + } + @NotNull public HttpInterfaceManager getHttpInterfaceManager() { return httpInterfaceManager; diff --git a/common/src/main/java/dev/lavalink/youtube/http/YoutubeAccessTokenTracker.java b/common/src/main/java/dev/lavalink/youtube/http/YoutubeAccessTokenTracker.java index 1a99ace..27dce22 100644 --- a/common/src/main/java/dev/lavalink/youtube/http/YoutubeAccessTokenTracker.java +++ b/common/src/main/java/dev/lavalink/youtube/http/YoutubeAccessTokenTracker.java @@ -4,6 +4,7 @@ import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterfaceManager; +import dev.lavalink.youtube.clients.Android; import dev.lavalink.youtube.clients.ClientConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; @@ -69,16 +70,10 @@ private String fetchVisitorId() throws IOException { try (HttpInterface httpInterface = httpInterfaceManager.getInterface()) { httpInterface.getContext().setAttribute(TOKEN_FETCH_CONTEXT_ATTRIBUTE, true); - ClientConfig clientConfig = new ClientConfig() - .withUserAgent("com.google.android.youtube/19.07.39 (Linux; U; Android 11) gzip") - .withClientName("ANDROID") - .withClientField("clientVersion", "19.07.39") - .withClientField("androidSdkVersion", 30) - .withUserField("lockedSafetyMode", false) - .setAttributes(httpInterface); + ClientConfig client = Android.BASE_CONFIG.setAttributes(httpInterface); HttpPost visitorIdPost = new HttpPost("https://youtubei.googleapis.com/youtubei/v1/visitor_id"); - visitorIdPost.setEntity(new StringEntity(clientConfig.toJsonString(), "UTF-8")); + visitorIdPost.setEntity(new StringEntity(client.toJsonString(), "UTF-8")); try (CloseableHttpResponse response = httpInterface.execute(visitorIdPost)) { HttpClientTools.assertSuccessWithContent(response, "youtube visitor id"); diff --git a/common/src/main/java/dev/lavalink/youtube/http/YoutubeHttpContextFilter.java b/common/src/main/java/dev/lavalink/youtube/http/YoutubeHttpContextFilter.java index f878896..939c73c 100644 --- a/common/src/main/java/dev/lavalink/youtube/http/YoutubeHttpContextFilter.java +++ b/common/src/main/java/dev/lavalink/youtube/http/YoutubeHttpContextFilter.java @@ -24,11 +24,16 @@ public class YoutubeHttpContextFilter extends BaseYoutubeHttpContextFilter { private static final HttpContextRetryCounter retryCounter = new HttpContextRetryCounter("yt-token-retry"); private YoutubeAccessTokenTracker tokenTracker; + private YoutubeOauth2Handler oauth2Handler; public void setTokenTracker(@NotNull YoutubeAccessTokenTracker tokenTracker) { this.tokenTracker = tokenTracker; } + public void setOauth2Handler(@NotNull YoutubeOauth2Handler oauth2Handler) { + this.oauth2Handler = oauth2Handler; + } + @Override public void onContextOpen(HttpClientContext context) { CookieStore cookieStore = context.getCookieStore(); @@ -57,16 +62,24 @@ public void onRequest(HttpClientContext context, return; } + if (oauth2Handler.isOauthFetchContext(context)) { + return; + } + String userAgent = context.getAttribute(ATTRIBUTE_USER_AGENT_SPECIFIED, String.class); - if (userAgent != null) { - request.setHeader("User-Agent", userAgent); + if (!request.getURI().getHost().contains("googlevideo")) { + if (userAgent != null) { + request.setHeader("User-Agent", userAgent); + + String visitorData = context.getAttribute(ATTRIBUTE_VISITOR_DATA_SPECIFIED, String.class); + request.setHeader("X-Goog-Visitor-Id", visitorData != null ? visitorData : tokenTracker.getVisitorId()); - String visitorData = context.getAttribute(ATTRIBUTE_VISITOR_DATA_SPECIFIED, String.class); - request.setHeader("X-Goog-Visitor-Id", visitorData != null ? visitorData : tokenTracker.getVisitorId()); + context.removeAttribute(ATTRIBUTE_VISITOR_DATA_SPECIFIED); + context.removeAttribute(ATTRIBUTE_USER_AGENT_SPECIFIED); + } - context.removeAttribute(ATTRIBUTE_VISITOR_DATA_SPECIFIED); - context.removeAttribute(ATTRIBUTE_USER_AGENT_SPECIFIED); + oauth2Handler.applyToken(request); } // try { diff --git a/common/src/main/java/dev/lavalink/youtube/http/YoutubeOauth2Handler.java b/common/src/main/java/dev/lavalink/youtube/http/YoutubeOauth2Handler.java new file mode 100644 index 0000000..340697a --- /dev/null +++ b/common/src/main/java/dev/lavalink/youtube/http/YoutubeOauth2Handler.java @@ -0,0 +1,296 @@ +package dev.lavalink.youtube.http; + +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonParserException; +import com.grack.nanojson.JsonWriter; +import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools; +import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterfaceManager; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public class YoutubeOauth2Handler { + private static final Logger log = LoggerFactory.getLogger(YoutubeOauth2Handler.class); + private static int fetchErrorLogCount = 0; + + // no, i haven't leaked anything of mine + // this (i presume) can be found within youtube's page source + // ¯\_(ツ)_/¯ + private static final String CLIENT_ID = "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com"; + private static final String CLIENT_SECRET = "SboVhoG9s0rNafixCSGGKXAT"; + private static final String SCOPES = "http://gdata.youtube.com https://www.googleapis.com/auth/youtube"; + private static final String OAUTH_FETCH_CONTEXT_ATTRIBUTE = "yt-oauth"; + + private final HttpInterfaceManager httpInterfaceManager; + + private boolean enabled; + private String refreshToken; + + private String tokenType; + private String accessToken; + private long tokenExpires; + + public YoutubeOauth2Handler(HttpInterfaceManager httpInterfaceManager) { + this.httpInterfaceManager = httpInterfaceManager; + } + + public void setRefreshToken(@Nullable String refreshToken, boolean skipInitialization) { + this.refreshToken = refreshToken; + this.tokenExpires = System.currentTimeMillis(); + this.accessToken = null; + + if (!DataFormatTools.isNullOrEmpty(refreshToken)) { + refreshAccessToken(true); + + // if refreshAccessToken() fails, enabled will never be flipped, so we don't use + // oauth tokens erroneously. + enabled = true; + return; + } + + if (!skipInitialization) { + initializeAccessToken(); + } + } + + public boolean shouldRefreshAccessToken() { + return enabled && !DataFormatTools.isNullOrEmpty(refreshToken) && (DataFormatTools.isNullOrEmpty(accessToken) || System.currentTimeMillis() >= tokenExpires); + } + + @Nullable + public String getRefreshToken() { + return refreshToken; + } + + public boolean isOauthFetchContext(HttpClientContext context) { + return context.getAttribute(OAUTH_FETCH_CONTEXT_ATTRIBUTE) == Boolean.TRUE; + } + + /** + * Makes a request to YouTube for a device code that users can then authorise to allow + * this source to make requests using an account access token. + * This will begin the oauth flow. If a refresh token is present, {@link #refreshAccessToken(boolean)} should + * be used instead. + */ + private void initializeAccessToken() { + JsonObject response = fetchDeviceCode(); + + log.debug("fetch device code response: {}", JsonWriter.string(response)); + + String verificationUrl = response.getString("verification_url"); + String userCode = response.getString("user_code"); + String deviceCode = response.getString("device_code"); + long interval = response.getLong("interval") * 1000; + + log.info("=================================================="); + log.info("!!! DO NOT AUTHORISE WITH YOUR MAIN ACCOUNT, USE A BURNER !!!"); + log.info("OAUTH INTEGRATION: To give youtube-source access to your account, go to {} and enter code {}", verificationUrl, userCode); + log.info("!!! DO NOT AUTHORISE WITH YOUR MAIN ACCOUNT, USE A BURNER !!!"); + log.info("=================================================="); + + // Should this be a daemon? + new Thread(() -> pollForToken(deviceCode, interval == 0 ? 5000 : interval), "youtube-source-token-poller").start(); + } + + private JsonObject fetchDeviceCode() { + // @formatter:off + String requestJson = JsonWriter.string() + .object() + .value("client_id", CLIENT_ID) + .value("scope", SCOPES) + .value("device_id", UUID.randomUUID().toString().replace("-", "")) + .value("device_model", "ytlr::") + .end() + .done(); + // @formatter:on + + HttpPost request = new HttpPost("https://www.youtube.com/o/oauth2/device/code"); + StringEntity body = new StringEntity(requestJson, ContentType.APPLICATION_JSON); + request.setEntity(body); + + try (HttpInterface httpInterface = getHttpInterface(); + CloseableHttpResponse response = httpInterface.execute(request)) { + HttpClientTools.assertSuccessWithContent(response, "device code fetch"); + return JsonParser.object().from(response.getEntity().getContent()); + } catch (IOException | JsonParserException e) { + throw ExceptionTools.toRuntimeException(e); + } + } + + private void pollForToken(String deviceCode, long interval) { + // @formatter:off + String requestJson = JsonWriter.string() + .object() + .value("client_id", CLIENT_ID) + .value("client_secret", CLIENT_SECRET) + .value("code", deviceCode) + .value("grant_type", "http://oauth.net/grant_type/device/1.0") + .end() + .done(); + // @formatter:on + + HttpPost request = new HttpPost("https://www.youtube.com/o/oauth2/token"); + StringEntity body = new StringEntity(requestJson, ContentType.APPLICATION_JSON); + request.setEntity(body); + + while (true) { + try (HttpInterface httpInterface = getHttpInterface(); + CloseableHttpResponse response = httpInterface.execute(request)) { + HttpClientTools.assertSuccessWithContent(response, "oauth2 token fetch"); + JsonObject parsed = JsonParser.object().from(response.getEntity().getContent()); + + log.debug("oauth2 token fetch response: {}", JsonWriter.string(parsed)); + + if (parsed.has("error") && !parsed.isNull("error")) { + String error = parsed.getString("error"); + + switch (error) { + case "authorization_pending": + case "slow_down": + Thread.sleep(interval); + continue; + case "expired_token": + log.error("OAUTH INTEGRATION: The device token has expired. OAuth integration has been canceled."); + case "access_denied": + log.error("OAUTH INTEGRATION: Account linking was denied. OAuth integration has been canceled."); + default: + log.error("Unhandled OAuth2 error: {}", error); + } + + return; + } + + updateTokens(parsed); + log.info("OAUTH INTEGRATION: Token retrieved successfully. Store your refresh token as this can be reused. ({})", refreshToken); + enabled = true; + return; + } catch (IOException | JsonParserException | InterruptedException e) { + log.error("Failed to fetch OAuth2 token response", e); + } + } + } + + /** + * Refreshes an access token using a supplied refresh token. + * @param force Whether to forcefully renew the access token, even if it doesn't necessarily + * need to be refreshed yet. + */ + public void refreshAccessToken(boolean force) { + log.debug("Refreshing access token (force: {})", force); + + if (DataFormatTools.isNullOrEmpty(refreshToken)) { + throw new IllegalStateException("Cannot fetch access token without a refresh token!"); + } + + if (!shouldRefreshAccessToken() && !force) { + log.debug("Access token does not need to be refreshed yet."); + return; + } + + synchronized (this) { + if (DataFormatTools.isNullOrEmpty(refreshToken)) { + throw new IllegalStateException("Cannot fetch access token without a refresh token!"); + } + + if (!shouldRefreshAccessToken() && !force) { + log.debug("Access token does not need to be refreshed yet."); + return; + } + + // @formatter:off + String requestJson = JsonWriter.string() + .object() + .value("client_id", CLIENT_ID) + .value("client_secret", CLIENT_SECRET) + .value("refresh_token", refreshToken) + .value("grant_type", "refresh_token") + .end() + .done(); + // @formatter:on + + HttpPost request = new HttpPost("https://www.youtube.com/o/oauth2/token"); + StringEntity entity = new StringEntity(requestJson, ContentType.APPLICATION_JSON); + request.setEntity(entity); + + try (HttpInterface httpInterface = getHttpInterface(); + CloseableHttpResponse response = httpInterface.execute(request)) { + HttpClientTools.assertSuccessWithContent(response, "oauth2 token fetch"); + JsonObject parsed = JsonParser.object().from(response.getEntity().getContent()); + + if (parsed.has("error") && !parsed.isNull("error")) { + throw new RuntimeException("Refreshing access token returned error " + parsed.getString("error")); + } + + updateTokens(parsed); + log.info("YouTube access token refreshed successfully"); + } catch (IOException | JsonParserException e) { + throw ExceptionTools.toRuntimeException(e); + } + } + } + + private void updateTokens(JsonObject json) { + long tokenLifespan = json.getLong("expires_in"); + tokenType = json.getString("token_type"); + accessToken = json.getString("access_token"); + refreshToken = json.getString("refresh_token", refreshToken); + tokenExpires = System.currentTimeMillis() + (tokenLifespan * 1000) - 60000; + + log.debug("OAuth access token is {} and refresh token is {}. Access token expires in {} seconds.", accessToken, refreshToken, tokenLifespan); + } + + public void applyToken(HttpUriRequest request) { + if (!enabled || DataFormatTools.isNullOrEmpty(refreshToken)) { + return; + } + + if (shouldRefreshAccessToken()) { + log.debug("Access token has expired, refreshing..."); + + try { + refreshAccessToken(false); + } catch (Throwable t) { + if (++fetchErrorLogCount <= 3) { + // log fetch errors up to 3 consecutive times to avoid spamming logs. in theory requests can still be made + // without an access token, but they are less likely to succeed. regardless, we shouldn't bloat a + // user's logs just in case YT changed something and broke oauth integration. + log.error("Refreshing YouTube access token failed", t); + } else { + log.debug("Refreshing YouTube access token failed", t); + } + + // retry in 15 seconds to avoid spamming YouTube with requests. + tokenExpires = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(15); + return; + } + + fetchErrorLogCount = 0; + } + + // check again to ensure updating worked as expected. + if (accessToken != null && tokenType != null && System.currentTimeMillis() < tokenExpires) { + log.debug("Using oauth authorization header with value \"{} {}\"", tokenType, accessToken); + request.setHeader("Authorization", String.format("%s %s", tokenType, accessToken)); + } + } + + private HttpInterface getHttpInterface() { + HttpInterface httpInterface = httpInterfaceManager.getInterface(); + httpInterface.getContext().setAttribute(OAUTH_FETCH_CONTEXT_ATTRIBUTE, true); + return httpInterface; + } +} diff --git a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeConfig.java b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeConfig.java index 86e5a43..6800bd2 100644 --- a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeConfig.java +++ b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeConfig.java @@ -17,6 +17,7 @@ public class YoutubeConfig { private Pot pot = null; private String[] clients; private Map clientOptions = new HashMap<>(); + private YoutubeOauthConfig oauth = null; public boolean getEnabled() { return enabled; @@ -46,6 +47,10 @@ public Map getClientOptions() { return clientOptions; } + public YoutubeOauthConfig getOauth() { + return this.oauth; + } + public void setEnabled(boolean enabled) { this.enabled = enabled; } @@ -73,4 +78,8 @@ public void setClients(String[] clients) { public void setClientOptions(Map clientOptions) { this.clientOptions = clientOptions; } + + public void setOauth(YoutubeOauthConfig oauth) { + this.oauth = oauth; + } } diff --git a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeOauthConfig.java b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeOauthConfig.java new file mode 100644 index 0000000..fee89c1 --- /dev/null +++ b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeOauthConfig.java @@ -0,0 +1,31 @@ +package dev.lavalink.youtube.plugin; + +public class YoutubeOauthConfig { + private boolean enabled = false; + private String refreshToken; + private boolean skipInitialization = false; + + public boolean getEnabled() { + return enabled; + } + + public String getRefreshToken() { + return refreshToken; + } + + public boolean getSkipInitialization() { + return skipInitialization; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void setSkipInitialization(boolean skipInitialization) { + this.skipInitialization = skipInitialization; + } +} diff --git a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubePluginLoader.java b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubePluginLoader.java index d26ad44..6776c6e 100644 --- a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubePluginLoader.java +++ b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubePluginLoader.java @@ -187,7 +187,7 @@ public AudioPlayerManager configure(AudioPlayerManager audioPlayerManager) { final int retryLimit = ratelimitConfig.getRetryLimit(); final YoutubeIpRotatorSetup rotator = new YoutubeIpRotatorSetup(routePlanner) .forConfiguration(source.getHttpInterfaceManager(), false) - .withMainDelegateFilter(null); // Necessary to avoid NPEs. + .withMainDelegateFilter(source.getContextFilter()); if (retryLimit == 0) { rotator.withRetryLimit(Integer.MAX_VALUE); @@ -204,6 +204,15 @@ public AudioPlayerManager configure(AudioPlayerManager audioPlayerManager) { source.setPlaylistPageCount(playlistLoadLimit); } + if (youtubeConfig != null && youtubeConfig.getOauth() != null) { + YoutubeOauthConfig oauthConfig = youtubeConfig.getOauth(); + + if (oauthConfig.getEnabled()) { + log.debug("Configuring youtube oauth integration with token: \"{}\" skipInitialization: {}", oauthConfig.getRefreshToken(), oauthConfig.getSkipInitialization()); + source.useOauth2(oauthConfig.getRefreshToken(), oauthConfig.getSkipInitialization()); + } + } + audioPlayerManager.registerSourceManager(source); return audioPlayerManager; } diff --git a/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeRestHandler.java b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeRestHandler.java new file mode 100644 index 0000000..006312d --- /dev/null +++ b/plugin/src/main/java/dev/lavalink/youtube/plugin/YoutubeRestHandler.java @@ -0,0 +1,51 @@ +package dev.lavalink.youtube.plugin; + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; +import dev.lavalink.youtube.YoutubeAudioSourceManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Collections; +import java.util.Map; + +@Service +public class YoutubeRestHandler { + private static final Logger log = LoggerFactory.getLogger(YoutubeRestHandler.class); + + private final AudioPlayerManager playerManager; + + public YoutubeRestHandler(AudioPlayerManager playerManager) { + this.playerManager = playerManager; + } + + @GetMapping("/v4/youtube") + public Map getYoutubeConfig() { + YoutubeAudioSourceManager source = playerManager.source(YoutubeAudioSourceManager.class); + + if (source == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The YouTube source manager is not registered."); + } + + return Collections.singletonMap("refreshToken", source.getOauth2RefreshToken()); + } + + @PostMapping("/v4/youtube") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updateOauth(@RequestBody YoutubeOauthConfig config) { + YoutubeAudioSourceManager source = playerManager.source(YoutubeAudioSourceManager.class); + + if (source == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The YouTube source manager is not registered."); + } + + source.useOauth2(config.getRefreshToken(), config.getSkipInitialization()); + log.debug("Updated YouTube OAuth2 refresh token to {}", config.getRefreshToken()); + } +}