Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Vimeo playback #149

Merged
merged 5 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager;
import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools;
import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
import com.sedmelluq.discord.lavaplayer.tools.*;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpConfigurable;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
Expand All @@ -19,14 +16,18 @@
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.impl.client.HttpClientBuilder;

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS;
Expand All @@ -35,7 +36,7 @@
* Audio source manager which detects Vimeo tracks by URL.
*/
public class VimeoAudioSourceManager implements AudioSourceManager, HttpConfigurable {
private static final String TRACK_URL_REGEX = "^https://vimeo.com/[0-9]+(?:\\?.*|)$";
private static final String TRACK_URL_REGEX = "^https?://vimeo.com/([0-9]+)(?:\\?.*|)$";
private static final Pattern trackUrlPattern = Pattern.compile(TRACK_URL_REGEX);

private final HttpInterfaceManager httpInterfaceManager;
Expand All @@ -54,13 +55,15 @@ public String getSourceName() {

@Override
public AudioItem loadItem(AudioPlayerManager manager, AudioReference reference) {
if (!trackUrlPattern.matcher(reference.identifier).matches()) {
Matcher trackUrl = trackUrlPattern.matcher(reference.identifier);

if (!trackUrl.matches()) {
return null;
}

try (HttpInterface httpInterface = httpInterfaceManager.getInterface()) {
return loadFromTrackPage(httpInterface, reference.identifier);
} catch (IOException e) {
return loadVideoFromApi(httpInterface, trackUrl.group(1));
} catch (IOException | URISyntaxException e) {
throw new FriendlyException("Loading Vimeo track information failed.", SUSPICIOUS, e);
}
}
Expand Down Expand Up @@ -149,4 +152,87 @@ private AudioTrack loadTrackFromPageContent(String trackUrl, String content) thr
null
), this);
}

private AudioTrack loadVideoFromApi(HttpInterface httpInterface, String videoId) throws IOException, URISyntaxException {
JsonBrowser videoData = getVideoFromApi(httpInterface, videoId);

AudioTrackInfo info = new AudioTrackInfo(
videoData.get("name").text(),
videoData.get("uploader").get("name").textOrDefault("Unknown artist"),
Units.secondsToMillis(videoData.get("duration").asLong(Units.DURATION_SEC_UNKNOWN)),
videoId,
false,
"https://vimeo.com/" + videoId,
videoData.get("pictures").get("base_link").text(),
null
);

return new VimeoAudioTrack(info, this);
}

public JsonBrowser getVideoFromApi(HttpInterface httpInterface, String videoId) throws IOException, URISyntaxException {
String jwt = getApiJwt(httpInterface);

URIBuilder builder = new URIBuilder("https://api.vimeo.com/videos/" + videoId);
// adding `play` to the fields achieves the same thing as requesting the config_url, but with one less request.
// maybe we should consider using that instead? Need to figure out what the difference is, if any.
builder.setParameter("fields", "config_url,name,uploader.name,duration,pictures");

HttpUriRequest request = new HttpGet(builder.build());
request.setHeader("Authorization", "jwt " + jwt);
request.setHeader("Accept", "application/json");

try (CloseableHttpResponse response = httpInterface.execute(request)) {
HttpClientTools.assertSuccessWithContent(response, "fetch video api");
return JsonBrowser.parse(response.getEntity().getContent());
}
}

public PlaybackFormat getPlaybackFormat(HttpInterface httpInterface, String configUrl) throws IOException {
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(configUrl))) {
HttpClientTools.assertSuccessWithContent(response, "fetch playback formats");

JsonBrowser json = JsonBrowser.parse(response.getEntity().getContent());

// {"dash", "hls", "progressive"}
// N.B. opus is referenced in some of the URLs, but I don't see any formats offering opus audio codec.
// Might be a gradual rollout so this may need revisiting.
JsonBrowser files = json.get("request").get("files");

if (!files.get("progressive").isNull()) {
JsonBrowser progressive = files.get("progressive").index(0);

if (!progressive.isNull()) {
return new PlaybackFormat(progressive.get("url").text(), false);
}
}

if (!files.get("hls").isNull()) {
JsonBrowser hls = files.get("hls");
// ["akfire_interconnect_quic", "fastly_skyfire"]
JsonBrowser cdns = hls.get("cdns");
return new PlaybackFormat(cdns.get(hls.get("default_cdn").text()).get("url").text(), true);
}

throw new RuntimeException("No supported formats");
}
}

private String getApiJwt(HttpInterface httpInterface) throws IOException {
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet("https://vimeo.com/_next/viewer"))) {
HttpClientTools.assertSuccessWithContent(response, "fetch jwt");
JsonBrowser json = JsonBrowser.parse(response.getEntity().getContent());
return json.get("jwt").text();
}
}

public static class PlaybackFormat {
public final String url;
public final boolean isHls;

public PlaybackFormat(String url, boolean isHls) {
this.url = url;
this.isHls = isHls;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class VimeoAudioTrack extends DelegatedAudioTrack {
private final VimeoAudioSourceManager sourceManager;

/**
* @param trackInfo Track info
* @param trackInfo Track info
* @param sourceManager Source manager which was used to find this track
*/
public VimeoAudioTrack(AudioTrackInfo trackInfo, VimeoAudioSourceManager sourceManager) {
Expand All @@ -47,81 +47,24 @@ public VimeoAudioTrack(AudioTrackInfo trackInfo, VimeoAudioSourceManager sourceM
@Override
public void process(LocalAudioTrackExecutor localExecutor) throws Exception {
try (HttpInterface httpInterface = sourceManager.getHttpInterface()) {
PlaybackSource playbackSource = getPlaybackSource(httpInterface);
JsonBrowser videoData = sourceManager.getVideoFromApi(httpInterface, trackInfo.identifier);
VimeoAudioSourceManager.PlaybackFormat playbackFormat = sourceManager.getPlaybackFormat(httpInterface, videoData.get("config_url").text());

log.debug("Starting Vimeo track. HLS: {}, URL: {}", playbackSource.isHls, playbackSource.url);
log.debug("Starting Vimeo track. HLS: {}, URL: {}", playbackFormat.isHls, playbackFormat.url);

if (playbackSource.isHls) {
processDelegate(new HlsStreamTrack(
trackInfo,
extractHlsAudioPlaylistUrl(httpInterface, playbackSource.url),
sourceManager.getHttpInterfaceManager(),
true
), localExecutor);
if (playbackFormat.isHls) {
processDelegate(
new HlsStreamTrack(trackInfo, extractHlsAudioPlaylistUrl(httpInterface, playbackFormat.url), sourceManager.getHttpInterfaceManager(), true),
localExecutor
);
} else {
try (PersistentHttpStream stream = new PersistentHttpStream(httpInterface, new URI(playbackSource.url), null)) {
try (PersistentHttpStream stream = new PersistentHttpStream(httpInterface, new URI(playbackFormat.url), null)) {
processDelegate(new MpegAudioTrack(trackInfo, stream), localExecutor);
}
}
}
}

private PlaybackSource getPlaybackSource(HttpInterface httpInterface) throws IOException {
JsonBrowser config = loadPlayerConfig(httpInterface);
if (config == null) {
throw new FriendlyException("Track information not present on the page.", SUSPICIOUS, null);
}

String trackConfigUrl = config.get("player").get("config_url").text();
JsonBrowser trackConfig = loadTrackConfig(httpInterface, trackConfigUrl);
JsonBrowser files = trackConfig.get("request").get("files");

if (!files.get("progressive").values().isEmpty()) {
String url = files.get("progressive").index(0).get("url").text();
return new PlaybackSource(url, false);
} else {
JsonBrowser hls = files.get("hls");
String defaultCdn = hls.get("default_cdn").text();
return new PlaybackSource(hls.get("cdns").get(defaultCdn).get("url").text(), true);
}
}

private static class PlaybackSource {
public String url;
public boolean isHls;

public PlaybackSource(String url, boolean isHls) {
this.url = url;
this.isHls = isHls;
}
}

private JsonBrowser loadPlayerConfig(HttpInterface httpInterface) throws IOException {
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(trackInfo.identifier))) {
int statusCode = response.getStatusLine().getStatusCode();

if (!HttpClientTools.isSuccessWithContent(statusCode)) {
throw new FriendlyException("Server responded with an error.", SUSPICIOUS,
new IllegalStateException("Response code for player config is " + statusCode));
}

return sourceManager.loadConfigJsonFromPageContent(IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8));
}
}

private JsonBrowser loadTrackConfig(HttpInterface httpInterface, String trackAccessInfoUrl) throws IOException {
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(trackAccessInfoUrl))) {
int statusCode = response.getStatusLine().getStatusCode();

if (!HttpClientTools.isSuccessWithContent(statusCode)) {
throw new FriendlyException("Server responded with an error.", SUSPICIOUS,
new IllegalStateException("Response code for track access info is " + statusCode));
}

return JsonBrowser.parse(response.getEntity().getContent());
}
}

protected String resolveRelativeUrl(String baseUrl, String url) {
while (url.startsWith("../")) {
url = url.substring(3);
Expand All @@ -145,16 +88,18 @@ private String extractHlsAudioPlaylistUrl(HttpInterface httpInterface, String vi
String bodyString = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
for (String rawLine : bodyString.split("\n")) {
ExtendedM3uParser.Line line = ExtendedM3uParser.parseLine(rawLine);
if (Objects.equals(line.directiveName, "EXT-X-MEDIA")
&& Objects.equals(line.directiveArguments.get("TYPE"), "AUDIO")) {

if (Objects.equals(line.directiveName, "EXT-X-MEDIA") && Objects.equals(line.directiveArguments.get("TYPE"), "AUDIO")) {
url = line.directiveArguments.get("URI");
break;
}
}
}

if (url == null) throw new FriendlyException("Failed to find audio playlist URL.", SUSPICIOUS,
new IllegalStateException("Valid audio directive was not found"));
if (url == null) {
throw new FriendlyException("Failed to find audio playlist URL.", SUSPICIOUS,
new IllegalStateException("Valid audio directive was not found"));
}

return resolveRelativeUrl(videoPlaylistUrl.substring(0, videoPlaylistUrl.lastIndexOf('/')), url);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
Expand Down Expand Up @@ -163,6 +164,20 @@ public List<JsonBrowser> values() {
return values;
}

/**
* Returns a list of all key names in this element if it's a map.
* @return The list of keys.
*/
public List<String> keys() {
if (!isMap()) {
return Collections.emptyList();
}

List<String> keys = new ArrayList<>();
node.fieldNames().forEachRemaining(keys::add);
return keys;
}

/**
* Attempt to retrieve the value in the specified format
*
Expand Down
Loading