Skip to content

Commit

Permalink
Implement Additional YouTube Clients (#36)
Browse files Browse the repository at this point in the history
* Add ANDROID_EMBEDDED and ANDROID_MUSIC clients.

* readme changes

* Remove ANDROID_EMBEDDED, properly implement ANDROID_MUSIC.

* clarify MEDIA_CONNECT does not receive mix videos
  • Loading branch information
devoxin authored Jul 24, 2024
1 parent f22d382 commit e05aa2c
Show file tree
Hide file tree
Showing 9 changed files with 206 additions and 40 deletions.
71 changes: 34 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,25 +122,24 @@ plugins:
- MUSIC
- ANDROID_TESTSUITE
- WEB
- TVHTML5EMBEDDED

# You can configure individual clients with the following.
# Any options or clients left unspecified will use their default values
# for those individual clients.
# Client configurations will ONLY take effect if the client is registered above,
# otherwise they are ignored.
# ---------------- WARNING ----------------
# This part of the config is for DEMONSTRATION PURPOSES ONLY!
# Do NOT use this config before understanding what the options do.
# You do NOT need to copy this config just because it is published here.
# ---------------- WARNING ----------------
WEB: # names are specified as they are written below under "Available Clients".
# This will disable using the WEB client for video playback.
# The below section of the config allows setting specific options for each client, such as the requests they will handle.
# If an option, or client, is unspecified, then the default option value/client values will be used instead.
# If a client is configured, but is not registered above, the options for that client will be ignored.

# WARNING!: THE BELOW CONFIG IS FOR ILLUSTRATION PURPOSES. DO NOT COPY OR USE THIS WITHOUT
# WARNING!: UNDERSTANDING WHAT IT DOES. MISCONFIGURATION WILL HINDER YOUTUBE-SOURCE'S ABILITY TO WORK PROPERLY.

# Write the names of clients as they are specified under the heading "Available Clients".
WEB:
# Example: Disabling a client's playback capabilities.
playback: false
TVHTML5EMBEDDED:
# The below config disables everything except playback for this client.
playlistLoading: false # Disables loading of playlists and mixes for this client.
videoLoading: false # Disables loading of videos for this client (playback is still allowed).
searching: false # Disables the ability to search for videos for this client.
# Example: Configuring a client to exclusively be used for playback.
playlistLoading: false # Disables loading of playlists and mixes.
videoLoading: false # Disables loading of videos for this client (does not affect playback).
searching: false # Disables the ability to search for videos.
```
> [!IMPORTANT]
Expand All @@ -152,39 +151,37 @@ lavalink:
youtube: false
```
Existing options, such as `ratelimit` and `youtubePlaylistLoadLimit` will be picked up automatically by the plugin,
so these don't need changing.
> [!NOTE]
> Existing options, such as `ratelimit` and `youtubePlaylistLoadLimit` will be picked up automatically by the plugin,
> so these don't need changing.

## Available Clients
Currently, the following clients are available for use:

- `MUSIC`
- Provides support for searching YouTube music (`ytmsearch:`)
- **This client CANNOT be used to play tracks.** You must also register one of the
below clients for track playback.
- ✔ Provides support for searching YouTube music (`ytmsearch:`).
- ❌ Cannot be used for playback.
- `WEB`
- ✔ Opus formats.
- `ANDROID`
- Usage of this client is no longer advised due to the frequency at which it breaks.
As of the time of writing, this client has been broken by YouTube with no known fix.
- ❌ Heavily restricted, frequently dysfunctional.
- `ANDROID_TESTSUITE`
- This client has restrictions imposed, notably: it does NOT support loading of mixes or playlists,
and it is unable to yield any supported formats when playing livestreams.
It is advised not to use this client on its own for that reason, if those features are required.
- ✔ Opus formats.
- ❌ No mix/playlist/livestream support. Advised to use in conjunction with other clients.
- `ANDROID_LITE`
- This client **does not receive Opus formats** so transcoding is required.
- Similar restrictions to that of `ANDROID_TESTSUITE` except livestreams are playable.
- ❌ No Opus formats (requires transcoding).
- ❌ Restricted similarly to `ANDROID_TESTSUITE` (except livestreams are playable).
- `ANDROID_MUSIC`
- ✔ Opus formats.
- ❌ No playlist support.
- `MEDIA_CONNECT`
- This client **does not receive Opus formats** so transcoding is required.
- This client has restrictions imposed, including but possibly not limited to:
- Unable to load playlists.
- Unable to use search.
- ❌ No Opus formats (requires transcoding).
- ❌ No mix/playlist/search support.
- `IOS`
- This client **does not receive Opus formats**, so transcoding is required. This can
increase resource consumption. It is recommended not to use this client unless it has
the least priority (specified last), or where hardware usage is not a concern.
- ❌ No Opus formats (requires transcoding).
- `TVHTML5EMBEDDED`
- This client is useful for playing age-restricted tracks. Do keep in mind that, even with this
client enabled, age-restricted tracks are **not** guaranteed to play.
- ✔ Opus formats.
- ✔ Age-restricted video playback.

## Migration from Lavaplayer's built-in YouTube source

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
public class YoutubeAudioSourceManager implements AudioSourceManager {
// TODO: connect timeout = 16000ms, read timeout = 8000ms (as observed from scraped youtube config)
// TODO: look at possibly scraping jsUrl from WEB config to save a request
// TODO: search providers use cookieless httpinterfacemanagers. should this do the same?
// TODO(music): scrape config? it's identical to WEB.

private static final Logger log = LoggerFactory.getLogger(YoutubeAudioSourceManager.class);
Expand Down
127 changes: 127 additions & 0 deletions common/src/main/java/dev/lavalink/youtube/clients/AndroidMusic.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package dev.lavalink.youtube.clients;

import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity;
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
import com.sedmelluq.discord.lavaplayer.tools.Units;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
import com.sedmelluq.discord.lavaplayer.track.AudioItem;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

public class AndroidMusic extends Android {
public static String CLIENT_VERSION = "6.42.52";

public static ClientConfig BASE_CONFIG = new ClientConfig()
.withApiKey(Android.BASE_CONFIG.getApiKey())
.withClientName("ANDROID_MUSIC")
.withClientField("clientVersion", CLIENT_VERSION)
.withUserAgent(String.format("com.google.android.apps.youtube.music/%s (Linux; U; Android %s) gzip", CLIENT_VERSION, ANDROID_VERSION.getOsVersion()));

public AndroidMusic() {
this(ClientOptions.DEFAULT);
}

public AndroidMusic(@NotNull ClientOptions options) {
super(options, false);
}

@Override
@NotNull
protected ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) {
return BASE_CONFIG.copy();
}

@Override
@NotNull
protected JsonBrowser extractMixPlaylistData(@NotNull JsonBrowser json) {
return json.get("contents")
.get("singleColumnMusicWatchNextResultsRenderer")
.get("tabbedRenderer")
.get("watchNextTabbedResultsRenderer")
.get("tabs")
.values()
.stream()
.filter(tab -> "Up next".equalsIgnoreCase(tab.get("tabRenderer").get("title").text()))
.findFirst()
.orElse(json)
.get("tabRenderer")
.get("content")
.get("musicQueueRenderer")
.get("content")
.get("playlistPanelRenderer");
}

@NotNull
protected List<AudioTrack> extractSearchResults(@NotNull YoutubeAudioSourceManager source,
@NotNull JsonBrowser json) {
return json.get("contents")
.get("tabbedSearchResultsRenderer")
.get("tabs")
.values()
.stream()
.flatMap(item -> item.get("tabRenderer").get("content").get("sectionListRenderer").get("contents").values().stream())
.map(item -> extractAudioTrack(item.get("musicCardShelfRenderer"), source))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}

@Override
@Nullable
protected AudioTrack extractAudioTrack(@NotNull JsonBrowser json, @NotNull YoutubeAudioSourceManager source) {
if (json.isNull() || !json.get("unplayableText").isNull()) return null;

AudioTrack track = super.extractAudioTrack(json, source);

if (track != null) {
return track;
}

String videoId = json.get("onTap").get("watchEndpoint").get("videoId").text();

if (videoId == null) {
return null;
}

JsonBrowser titleJson = json.get("title");
JsonBrowser secondaryJson = json.get("menu").get("menuRenderer").get("title").get("musicMenuTitleRenderer").get("secondaryText").get("runs");
String title = DataFormatTools.defaultOnNull(titleJson.get("runs").index(0).get("text").text(), titleJson.get("simpleText").text());
String author = secondaryJson.index(0).get("text").text();

JsonBrowser durationJson = secondaryJson.index(2);
String durationText = DataFormatTools.defaultOnNull(durationJson.get("text").text(), durationJson.get("runs").index(0).get("text").text());

long duration = DataFormatTools.durationTextToMillis(durationText);
return buildAudioTrack(source, json, title, author, duration, videoId, false);
}

@Override
public boolean canHandleRequest(@NotNull String identifier) {
// loose check to avoid loading playlists.
// this client does support them, but it seems to be missing fields (i.e. videoId)
return (!identifier.contains("list=") || identifier.contains("list=RD")) && super.canHandleRequest(identifier);
}

@Override
@NotNull
public String getIdentifier() {
return BASE_CONFIG.getName();
}

@Override
public AudioItem loadPlaylist(@NotNull YoutubeAudioSourceManager source, @NotNull HttpInterface httpInterface, @NotNull String playlistId, @Nullable String selectedVideoId) {
// It does actually return JSON, but it seems like videoId is missing.
// Each video JSON contains a "Content is unavailable" message.
// Theoretically, you could construct an audio track from the JSON as author, duration and title are there.
// Video ID is included in the thumbnail URL, but I don't think it's worth writing parsing for.
throw new FriendlyException("This client cannot load playlists", Severity.COMMON,
new RuntimeException("ANDROID_MUSIC cannot be used to load playlists"));
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package dev.lavalink.youtube.clients;

import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
import com.sedmelluq.discord.lavaplayer.track.AudioItem;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.clients.skeleton.StreamingNonMusicClient;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

public class MediaConnect extends StreamingNonMusicClient {
public static ClientConfig BASE_CONFIG = new ClientConfig()
Expand Down Expand Up @@ -41,7 +45,7 @@ public ClientOptions getOptions() {
@Override
public boolean canHandleRequest(@NotNull String identifier) {
// This client appears to be able to load livestreams and videos, but will
// receive 400 bad request when loading playlists.
// receive 400 bad request when loading playlists. mixes do return JSON, but does not contain mix videos.
return !identifier.startsWith(YoutubeAudioSourceManager.SEARCH_PREFIX) && !identifier.contains("list=") && super.canHandleRequest(identifier);
}

Expand All @@ -50,4 +54,22 @@ public boolean canHandleRequest(@NotNull String identifier) {
public String getIdentifier() {
return BASE_CONFIG.getName();
}

@Override
public AudioItem loadSearch(@NotNull YoutubeAudioSourceManager source, @NotNull HttpInterface httpInterface, @NotNull String searchQuery) {
throw new FriendlyException("This client cannot load searches", Severity.COMMON,
new RuntimeException("MEDIA_CONNECT cannot be used to load searches"));
}

@Override
public AudioItem loadPlaylist(@NotNull YoutubeAudioSourceManager source, @NotNull HttpInterface httpInterface, @NotNull String playlistId, @Nullable String selectedVideoId) {
throw new FriendlyException("This client cannot load playlists", Severity.COMMON,
new RuntimeException("MEDIA_CONNECT cannot be used to load playlists"));
}

@Override
public AudioItem loadMix(@NotNull YoutubeAudioSourceManager source, @NotNull HttpInterface httpInterface, @NotNull String mixId, @Nullable String selectedVideoId) {
throw new FriendlyException("This client cannot load mixes", Severity.COMMON,
new RuntimeException("MEDIA_CONNECT cannot be used to load mixes"));
}
}
2 changes: 2 additions & 0 deletions common/src/main/java/dev/lavalink/youtube/clients/Music.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public ClientConfig getBaseClientConfig(@NotNull HttpInterface httpInterface) {
public String getPlayerParams() {
// This client is not used for format loading so, we don't have
// any player parameters attached to it.
// TODO?: This client *can* do playback, so maybe look into allowing
// this client to be used in playback rotation.
throw new UnsupportedOperationException();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package dev.lavalink.youtube.clients;

import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity;
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
import com.sedmelluq.discord.lavaplayer.tools.Units;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
Expand Down Expand Up @@ -110,6 +112,7 @@ public AudioItem loadPlaylist(@NotNull YoutubeAudioSourceManager source,
@NotNull HttpInterface httpInterface,
@NotNull String playlistId,
@Nullable String selectedVideoId) {
throw new UnsupportedOperationException();
throw new FriendlyException("This client cannot load playlists", Severity.COMMON,
new RuntimeException("TVHTML5_EMBEDDED cannot be used to load playlists"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ private enum ClientMapping implements ClientReference {
ANDROID(Android::new),
ANDROID_TESTSUITE(AndroidTestsuite::new),
ANDROID_LITE(AndroidLite::new),
ANDROID_MUSIC(AndroidMusic::new),
IOS(Ios::new),
MUSIC(Music::new),
TVHTML5EMBEDDED(TvHtml5Embedded::new),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ private enum ClientMapping implements ClientReference {
ANDROID(AndroidWithThumbnail::new),
ANDROID_TESTSUITE(AndroidTestsuiteWithThumbnail::new),
ANDROID_LITE(AndroidLiteWithThumbnail::new),
ANDROID_MUSIC(AndroidMusicWithThumbnail::new),
IOS(IosWithThumbnail::new),
MUSIC(MusicWithThumbnail::new),
TVHTML5EMBEDDED(TvHtml5EmbeddedWithThumbnail::new),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package dev.lavalink.youtube.clients;

import dev.lavalink.youtube.clients.skeleton.NonMusicClientWithThumbnail;
import org.jetbrains.annotations.NotNull;

public class AndroidMusicWithThumbnail extends AndroidMusic implements NonMusicClientWithThumbnail {
public AndroidMusicWithThumbnail() {
super();
}

public AndroidMusicWithThumbnail(@NotNull ClientOptions options) {
super(options);
}
}

0 comments on commit e05aa2c

Please sign in to comment.