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 cb56e5c0..e9df3f4a 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); } } @@ -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; + } + } } 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 be1c6e6d..6f17dd51 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 @@ -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) { @@ -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); @@ -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); } 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 999eb4d2..d88ae3b6 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; /** @@ -163,6 +164,20 @@ public List 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 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 *