From 588a7a698c3953958d58d7cecf6d6e8e1b0cefcb Mon Sep 17 00:00:00 2001 From: Freya Arbjerg Date: Tue, 17 Sep 2024 13:01:31 +0200 Subject: [PATCH] Merge pull request #149 from lavalink-devs/fix/vimeo-playback --- .../source/vimeo/VimeoAudioSourceManager.java | 104 +++++++++++++++-- .../source/vimeo/VimeoAudioTrack.java | 110 ++++++++++-------- .../discord/lavaplayer/tools/JsonBrowser.java | 15 +++ 3 files changed, 172 insertions(+), 57 deletions(-) diff --git a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/vimeo/VimeoAudioSourceManager.java b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/vimeo/VimeoAudioSourceManager.java index ea678893..35bd8233 100644 --- a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/vimeo/VimeoAudioSourceManager.java +++ b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/vimeo/VimeoAudioSourceManager.java @@ -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; @@ -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; @@ -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; @@ -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); } } @@ -85,6 +88,10 @@ public void shutdown() { ExceptionTools.closeWithWarnings(httpInterfaceManager); } + public HttpInterfaceManager getHttpInterfaceManager() { + return httpInterfaceManager; + } + /** * @return Get an HTTP interface for a playing track. */ @@ -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; + } + } } diff --git a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/vimeo/VimeoAudioTrack.java b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/vimeo/VimeoAudioTrack.java index 7684ad5d..9c14b103 100644 --- a/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/vimeo/VimeoAudioTrack.java +++ b/main/src/main/java/com/sedmelluq/discord/lavaplayer/source/vimeo/VimeoAudioTrack.java @@ -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; @@ -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; @@ -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) { @@ -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() { diff --git a/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/JsonBrowser.java b/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/JsonBrowser.java index 2751c85f..a838b6b3 100644 --- a/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/JsonBrowser.java +++ b/main/src/main/java/com/sedmelluq/discord/lavaplayer/tools/JsonBrowser.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -103,6 +104,20 @@ public List values() { } /** + * Returns a list of all key names in this element if it's a map. + * @return The list of keys. + */ + public List keys() { + if (!isMap()) { + return Collections.emptyList(); + } + + List 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