Skip to content

Commit

Permalink
Support OAuth2 Account Integration (#33)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
devoxin authored Aug 19, 2024
1 parent d97bf92 commit e3f00f4
Show file tree
Hide file tree
Showing 9 changed files with 509 additions and 21 deletions.
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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();
```

Expand Down Expand Up @@ -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);
```

<!-- TODO document rest routes -->

### 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 + ")/(?<videoId>.*)");

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);
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit e3f00f4

Please sign in to comment.