Skip to content

Commit

Permalink
Merge pull request #149 from lavalink-devs/fix/vimeo-playback
Browse files Browse the repository at this point in the history
  • Loading branch information
freyacodes authored and topi314 committed Sep 18, 2024
1 parent 5492616 commit 588a7a6
Show file tree
Hide file tree
Showing 3 changed files with 172 additions and 57 deletions.
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 All @@ -85,6 +88,10 @@ public void shutdown() {
ExceptionTools.closeWithWarnings(httpInterfaceManager);
}

public HttpInterfaceManager getHttpInterfaceManager() {
return httpInterfaceManager;
}

/**
* @return Get an HTTP interface for a playing track.
*/
Expand Down Expand Up @@ -143,4 +150,85 @@ private AudioTrack loadTrackFromPageContent(String trackUrl, String content) thr
trackUrl
), 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
);

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
@@ -1,6 +1,8 @@
package com.sedmelluq.discord.lavaplayer.source.vimeo;

import com.sedmelluq.discord.lavaplayer.container.mpeg.MpegAudioTrack;
import com.sedmelluq.discord.lavaplayer.container.playlists.ExtendedM3uParser;
import com.sedmelluq.discord.lavaplayer.container.playlists.HlsStreamTrack;
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
Expand All @@ -20,6 +22,7 @@
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Objects;

import static com.sedmelluq.discord.lavaplayer.tools.FriendlyException.Severity.SUSPICIOUS;

Expand All @@ -32,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 @@ -41,61 +44,70 @@ public VimeoAudioTrack(AudioTrackInfo trackInfo, VimeoAudioSourceManager sourceM
this.sourceManager = sourceManager;
}

@Override
public void process(LocalAudioTrackExecutor localExecutor) throws Exception {
try (HttpInterface httpInterface = sourceManager.getHttpInterface()) {
String playbackUrl = loadPlaybackUrl(httpInterface);

log.debug("Starting Vimeo track from URL: {}", playbackUrl);

try (PersistentHttpStream stream = new PersistentHttpStream(httpInterface, new URI(playbackUrl), null)) {
processDelegate(new MpegAudioTrack(trackInfo, stream), localExecutor);
}
@Override
public void process(LocalAudioTrackExecutor localExecutor) throws Exception {
try (HttpInterface httpInterface = sourceManager.getHttpInterface()) {
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: {}", playbackFormat.isHls, playbackFormat.url);

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

private String loadPlaybackUrl(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);

return trackConfig.get("request").get("files").get("progressive").index(0).get("url").text();
}

private JsonBrowser loadPlayerConfig(HttpInterface httpInterface) throws IOException {
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(trackInfo.identifier))) {
int statusCode = response.getStatusLine().getStatusCode();
protected String resolveRelativeUrl(String baseUrl, String url) {
while (url.startsWith("../")) {
url = url.substring(3);
baseUrl = baseUrl.substring(0, baseUrl.lastIndexOf('/'));
}

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));
return baseUrl + ((url.startsWith("/")) ? url : "/" + url);
}
}

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());
/** Vimeo HLS uses separate audio and video. This extracts the audio playlist URL from EXT-X-MEDIA */
private String extractHlsAudioPlaylistUrl(HttpInterface httpInterface, String videoPlaylistUrl) throws IOException {
String url = null;
try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(videoPlaylistUrl))) {
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));
}

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")) {
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"));
}

return resolveRelativeUrl(videoPlaylistUrl.substring(0, videoPlaylistUrl.lastIndexOf('/')), url);
}
}

@Override
protected AudioTrack makeShallowClone() {
return new VimeoAudioTrack(trackInfo, sourceManager);
}
@Override
protected AudioTrack makeShallowClone() {
return new VimeoAudioTrack(trackInfo, sourceManager);
}

@Override
public AudioSourceManager getSourceManager() {
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 @@ -103,6 +104,20 @@ public List<JsonBrowser> 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
*
* @param klass The class to retrieve the value as
Expand Down

0 comments on commit 588a7a6

Please sign in to comment.