From 8c39236bb8991ea5c156fbebfeeda793e8a0f47e Mon Sep 17 00:00:00 2001 From: Devoxin Date: Wed, 10 Apr 2024 18:47:37 +0100 Subject: [PATCH 1/4] Add support for FLAC streaming. --- .../deezer/DeezerAudioSourceManager.java | 15 +- .../lavasrc/deezer/DeezerAudioTrack.java | 136 +++++++++++++++--- 2 files changed, 133 insertions(+), 18 deletions(-) diff --git a/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java b/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java index 5fe2b16a..a98124b7 100644 --- a/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java +++ b/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java @@ -48,14 +48,22 @@ public class DeezerAudioSourceManager extends ExtendedAudioSourceManager impleme private static final Logger log = LoggerFactory.getLogger(DeezerAudioSourceManager.class); private final String masterDecryptionKey; + private final String arl; private final HttpInterfaceManager httpInterfaceManager; public DeezerAudioSourceManager(String masterDecryptionKey) { + this(masterDecryptionKey, null); + + } + + public DeezerAudioSourceManager(String masterDecryptionKey, @Nullable String arl) { if (masterDecryptionKey == null || masterDecryptionKey.isEmpty()) { throw new IllegalArgumentException("Deezer master key must be set"); } + this.masterDecryptionKey = masterDecryptionKey; - this.httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager(); + this.arl = arl != null && arl.isEmpty() ? null : arl; + this.httpInterfaceManager = HttpClientTools.createCookielessThreadLocalManager(); } @NotNull @@ -357,6 +365,11 @@ public String getMasterDecryptionKey() { return this.masterDecryptionKey; } + @Nullable + public String getArl() { + return this.arl; + } + public HttpInterface getHttpInterface() { return this.httpInterfaceManager.getInterface(); } diff --git a/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioTrack.java b/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioTrack.java index d1ddf4cf..433c8873 100644 --- a/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioTrack.java +++ b/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioTrack.java @@ -2,29 +2,41 @@ import com.github.topi314.lavasrc.ExtendedAudioTrack; import com.github.topi314.lavasrc.LavaSrcTools; +import com.sedmelluq.discord.lavaplayer.container.flac.FlacAudioTrack; import com.sedmelluq.discord.lavaplayer.container.mp3.Mp3AudioTrack; import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager; +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.Units; +import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; import com.sedmelluq.discord.lavaplayer.tools.io.PersistentHttpStream; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import com.sedmelluq.discord.lavaplayer.track.InternalAudioTrack; import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor; import org.apache.commons.codec.binary.Hex; +import org.apache.http.client.CookieStore; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.BasicCookieStore; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.function.BiFunction; import java.util.stream.Collectors; public class DeezerAudioTrack extends ExtendedAudioTrack { private final DeezerAudioSourceManager sourceManager; + private final CookieStore cookieStore; public DeezerAudioTrack(AudioTrackInfo trackInfo, DeezerAudioSourceManager sourceManager) { this(trackInfo, null, null, null, null, null, false, sourceManager); @@ -33,43 +45,101 @@ public DeezerAudioTrack(AudioTrackInfo trackInfo, DeezerAudioSourceManager sourc public DeezerAudioTrack(AudioTrackInfo trackInfo, String albumName, String albumUrl, String artistUrl, String artistArtworkUrl, String previewUrl, boolean isPreview, DeezerAudioSourceManager sourceManager) { super(trackInfo, albumName, albumUrl, artistUrl, artistArtworkUrl, previewUrl, isPreview); this.sourceManager = sourceManager; + this.cookieStore = new BasicCookieStore(); } - private URI getTrackMediaURI() throws IOException, URISyntaxException { - var getSessionID = new HttpPost(DeezerAudioSourceManager.PRIVATE_API_BASE + "?method=deezer.ping&input=3&api_version=1.0&api_token="); - var json = LavaSrcTools.fetchResponseAsJson(this.sourceManager.getHttpInterface(), getSessionID); + private JsonBrowser getJsonResponse(HttpUriRequest request, boolean includeArl) { + try (HttpInterface httpInterface = this.sourceManager.getHttpInterface()) { + httpInterface.getContext().setRequestConfig(RequestConfig.custom().setCookieSpec("standard").build()); + httpInterface.getContext().setCookieStore(cookieStore); - this.checkResponse(json, "Failed to get session ID: "); - var sessionID = json.get("results").get("SESSION").text(); + if (includeArl && this.sourceManager.getArl() != null) { + request.setHeader("Cookie", "arl=" + this.sourceManager.getArl()); + } + + return LavaSrcTools.fetchResponseAsJson(httpInterface, request); + } catch (IOException e) { + throw ExceptionTools.toRuntimeException(e); + } + } + + private String getSessionId() { + final HttpPost getSessionID = new HttpPost(DeezerAudioSourceManager.PRIVATE_API_BASE + "?method=deezer.ping&input=3&api_version=1.0&api_token="); + final JsonBrowser sessionIdJson = this.getJsonResponse(getSessionID, false); - var getUserToken = new HttpPost(DeezerAudioSourceManager.PRIVATE_API_BASE + "?method=deezer.getUserData&input=3&api_version=1.0&api_token="); - getUserToken.setHeader("Cookie", "sid=" + sessionID); - json = LavaSrcTools.fetchResponseAsJson(this.sourceManager.getHttpInterface(), getUserToken); + this.checkResponse(sessionIdJson, "Failed to get session ID: "); + if (sessionIdJson.get("data").index(0).get("errors").index(0).get("code").asLong(0) != 0) { + throw new IllegalStateException("Failed to get session ID"); + } + + return sessionIdJson.get("results").get("SESSION").text(); + } + private JsonBrowser generateLicenceToken(boolean useArl) { + final HttpGet request = new HttpGet(DeezerAudioSourceManager.PRIVATE_API_BASE + "?method=deezer.getUserData&input=3&api_version=1.0&api_token="); + + // session ID is not needed with ARL and vice-versa. + if (!useArl || this.sourceManager.getArl() == null) { + request.setHeader("Cookie", "sid=" + this.getSessionId()); + } + + return this.getJsonResponse(request, useArl); + } + + private SourceWithFormat getSource(boolean tryFlac, boolean isRetry) throws URISyntaxException { + var json = this.generateLicenceToken(tryFlac); this.checkResponse(json, "Failed to get user token: "); + var userLicenseToken = json.get("results").get("USER").get("OPTIONS").get("license_token").text(); var apiToken = json.get("results").get("checkForm").text(); var getTrackToken = new HttpPost(DeezerAudioSourceManager.PRIVATE_API_BASE + "?method=song.getData&input=3&api_version=1.0&api_token=" + apiToken); getTrackToken.setEntity(new StringEntity("{\"sng_id\":\"" + this.trackInfo.identifier + "\"}", ContentType.APPLICATION_JSON)); - json = LavaSrcTools.fetchResponseAsJson(this.sourceManager.getHttpInterface(), getTrackToken); + var trackTokenJson = this.getJsonResponse(getTrackToken, tryFlac); - this.checkResponse(json, "Failed to get track token: "); - var trackToken = json.get("results").get("TRACK_TOKEN").text(); + this.checkResponse(trackTokenJson, "Failed to get track token: "); + if (trackTokenJson.get("error").get("VALID_TOKEN_REQUIRED").text() != null && !isRetry) { + // "error":{"VALID_TOKEN_REQUIRED":"Invalid CSRF token"} + // seems to indicate an invalid API token? + return this.getSource(tryFlac, true); + } + + if (tryFlac && trackTokenJson.get("results").get("FILESIZE_FLAC").asLong(0) == 0) { + // no flac format available. + return this.getSource(false, false); + } + + var trackToken = trackTokenJson.get("results").get("TRACK_TOKEN").text(); var getMediaURL = new HttpPost(DeezerAudioSourceManager.MEDIA_BASE + "/get_url"); - getMediaURL.setEntity(new StringEntity("{\"license_token\":\"" + userLicenseToken + "\",\"media\": [{\"type\": \"FULL\",\"formats\": [{\"cipher\": \"BF_CBC_STRIPE\", \"format\": \"MP3_128\"}]}],\"track_tokens\": [\"" + trackToken + "\"]}", ContentType.APPLICATION_JSON)); - json = LavaSrcTools.fetchResponseAsJson(this.sourceManager.getHttpInterface(), getMediaURL); - this.checkResponse(json, "Failed to get media URL: "); - return new URI(json.get("data").index(0).get("media").index(0).get("sources").index(0).get("url").text()); + getMediaURL.setEntity(new StringEntity("{\"license_token\":\"" + userLicenseToken + "\",\"media\":[{\"type\":\"FULL\",\"formats\":[{\"cipher\":\"BF_CBC_STRIPE\",\"format\":\"MP3_128\"}]}],\"track_tokens\": [\"" + trackToken + "\"]}", ContentType.APPLICATION_JSON)); + json = this.getJsonResponse(getMediaURL, tryFlac); + + try { + this.checkResponse(json, "Failed to get media URL: "); + } catch (IllegalStateException e) { + // error code 2000 = failed to decode track token + if (e.getMessage().contains("2000:") && !isRetry) { + return this.getSource(tryFlac, true); + } else if (tryFlac) { + cookieStore.clear(); + return this.getSource(false, false); // Try again but for MP3_128. + } else { + throw e; + } + } + + return SourceWithFormat.fromResponse(json, trackTokenJson); } private void checkResponse(JsonBrowser json, String message) throws IllegalStateException { if (json == null) { throw new IllegalStateException(message + "No response"); } + var errors = json.get("data").index(0).get("errors").values(); + if (!errors.isEmpty()) { var errorsStr = errors.stream().map(error -> error.get("code").text() + ": " + error.get("message").text()).collect(Collectors.joining(", ")); throw new IllegalStateException(message + errorsStr); @@ -94,12 +164,15 @@ public void process(LocalAudioTrackExecutor executor) throws Exception { if (this.previewUrl == null) { throw new FriendlyException("No preview url found", FriendlyException.Severity.COMMON, new IllegalArgumentException()); } + try (var stream = new PersistentHttpStream(httpInterface, new URI(this.previewUrl), this.trackInfo.length)) { processDelegate(new Mp3AudioTrack(this.trackInfo, stream), executor); } } else { - try (var stream = new DeezerPersistentHttpStream(httpInterface, this.getTrackMediaURI(), this.trackInfo.length, this.getTrackDecryptionKey())) { - processDelegate(new Mp3AudioTrack(this.trackInfo, stream), executor); + SourceWithFormat source = this.getSource(this.sourceManager.getArl() != null, false); + + try (var stream = new DeezerPersistentHttpStream(httpInterface, source.url, source.contentLength, this.getTrackDecryptionKey())) { + processDelegate(source.getTrackFactory().apply(this.trackInfo, stream), executor); } } } @@ -115,4 +188,33 @@ public AudioSourceManager getSourceManager() { return this.sourceManager; } + private static class SourceWithFormat { + private final URI url; + private final String format; + private final long contentLength; + + private SourceWithFormat(String url, String format, long contentLength) throws URISyntaxException { + this.url = new URI(url); + this.format = format; + this.contentLength = contentLength; + } + + private BiFunction getTrackFactory() { + return this.format.equals("FLAC") ? FlacAudioTrack::new : Mp3AudioTrack::new; + } + + private static SourceWithFormat fromResponse(JsonBrowser json, JsonBrowser trackJson) throws URISyntaxException { + JsonBrowser media = json.get("data").index(0).get("media").index(0); + JsonBrowser sources = media.get("sources"); + + if (media.isNull()) { + return null; + } + + String format = media.get("format").text(); + String url = sources.index(0).get("url").text(); + long contentLength = trackJson.get("results").get("FILESIZE_" + format).asLong(Units.CONTENT_LENGTH_UNKNOWN); + return new SourceWithFormat(url, format, contentLength); + } + } } From 63dd5d569b360088f67fd18de3e8e0e7201b60a7 Mon Sep 17 00:00:00 2001 From: Devoxin Date: Wed, 10 Apr 2024 18:50:02 +0100 Subject: [PATCH 2/4] Add config options for the plugin. --- .../com/github/topi314/lavasrc/plugin/DeezerConfig.java | 9 +++++++++ .../com/github/topi314/lavasrc/plugin/LavaSrcPlugin.java | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/plugin/src/main/java/com/github/topi314/lavasrc/plugin/DeezerConfig.java b/plugin/src/main/java/com/github/topi314/lavasrc/plugin/DeezerConfig.java index 74de84ab..8f1418bd 100644 --- a/plugin/src/main/java/com/github/topi314/lavasrc/plugin/DeezerConfig.java +++ b/plugin/src/main/java/com/github/topi314/lavasrc/plugin/DeezerConfig.java @@ -8,13 +8,22 @@ public class DeezerConfig { private String masterDecryptionKey; + private String arl; public String getMasterDecryptionKey() { return this.masterDecryptionKey; } + public String getArl() { + return this.arl; + } + public void setMasterDecryptionKey(String masterDecryptionKey) { this.masterDecryptionKey = masterDecryptionKey; } + public void setArl(String arl) { + this.arl = arl; + } + } diff --git a/plugin/src/main/java/com/github/topi314/lavasrc/plugin/LavaSrcPlugin.java b/plugin/src/main/java/com/github/topi314/lavasrc/plugin/LavaSrcPlugin.java index 698769ae..eda91286 100644 --- a/plugin/src/main/java/com/github/topi314/lavasrc/plugin/LavaSrcPlugin.java +++ b/plugin/src/main/java/com/github/topi314/lavasrc/plugin/LavaSrcPlugin.java @@ -52,7 +52,7 @@ public LavaSrcPlugin(LavaSrcConfig pluginConfig, SourcesConfig sourcesConfig, Sp } } if (sourcesConfig.isDeezer()) { - this.deezer = new DeezerAudioSourceManager(deezerConfig.getMasterDecryptionKey()); + this.deezer = new DeezerAudioSourceManager(deezerConfig.getMasterDecryptionKey(), deezerConfig.getArl()); } if (sourcesConfig.isYandexMusic()) { this.yandexMusic = new YandexMusicSourceManager(yandexMusicConfig.getAccessToken()); From e8d139f6fce11efd9f99d3337b59b2aa7ec79f76 Mon Sep 17 00:00:00 2001 From: Devoxin Date: Wed, 10 Apr 2024 18:50:36 +0100 Subject: [PATCH 3/4] Remove blank line --- .../github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java | 1 - 1 file changed, 1 deletion(-) diff --git a/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java b/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java index a98124b7..63be391d 100644 --- a/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java +++ b/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java @@ -53,7 +53,6 @@ public class DeezerAudioSourceManager extends ExtendedAudioSourceManager impleme public DeezerAudioSourceManager(String masterDecryptionKey) { this(masterDecryptionKey, null); - } public DeezerAudioSourceManager(String masterDecryptionKey, @Nullable String arl) { From b920eff99189f5e7d2444678321151ff5751253b Mon Sep 17 00:00:00 2001 From: Devoxin Date: Wed, 10 Apr 2024 18:52:26 +0100 Subject: [PATCH 4/4] Also request FLAC format. --- .../com/github/topi314/lavasrc/deezer/DeezerAudioTrack.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioTrack.java b/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioTrack.java index 433c8873..682341c2 100644 --- a/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioTrack.java +++ b/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioTrack.java @@ -113,7 +113,7 @@ private SourceWithFormat getSource(boolean tryFlac, boolean isRetry) throws URIS var trackToken = trackTokenJson.get("results").get("TRACK_TOKEN").text(); var getMediaURL = new HttpPost(DeezerAudioSourceManager.MEDIA_BASE + "/get_url"); - getMediaURL.setEntity(new StringEntity("{\"license_token\":\"" + userLicenseToken + "\",\"media\":[{\"type\":\"FULL\",\"formats\":[{\"cipher\":\"BF_CBC_STRIPE\",\"format\":\"MP3_128\"}]}],\"track_tokens\": [\"" + trackToken + "\"]}", ContentType.APPLICATION_JSON)); + getMediaURL.setEntity(new StringEntity("{\"license_token\":\"" + userLicenseToken + "\",\"media\":[{\"type\":\"FULL\",\"formats\":[{\"cipher\":\"BF_CBC_STRIPE\",\"format\":\"" + (tryFlac ? "FLAC" : "MP3_128") + "\"}]}],\"track_tokens\": [\"" + trackToken + "\"]}", ContentType.APPLICATION_JSON)); json = this.getJsonResponse(getMediaURL, tryFlac); try {