diff --git a/README.md b/README.md index f2be0954..caee9c92 100644 --- a/README.md +++ b/README.md @@ -171,10 +171,25 @@ AudioPlayerManager playerManager = new DefaultAudioPlayerManager(); // create a new DeezerSourceManager with the master decryption key and register it -var deezer = new DeezerSourceManager("..."); +var deezer = new DeezerSourceManager("the master decryption key", "your arl", formats); playerManager.registerSourceManager(deezer); ``` +
+How to get deezer master decryption key + +Use google. + +
+ +
+How to get deezer arl cookie + +Use google to find a guide on how to get the arl cookie. It's not that hard. + +
+ + #### LavaLyrics
Click to expand @@ -312,6 +327,8 @@ To get your Spotify spDc cookie go [here](#spotify) To get your Apple Music api token go [here](#apple-music) +To get your Deezer arl cookie go [here](#deezer) + To get your Yandex Music access token go [here](#yandex-music) (YES `plugins` IS AT ROOT IN THE YAML) @@ -358,6 +375,8 @@ plugins: albumLoadLimit: 6 # The number of pages at 300 tracks each deezer: masterDecryptionKey: "your master decryption key" # the master key used for decrypting the deezer tracks. (yes this is not here you need to get it from somewhere else) + arl: "your deezer arl" # the arl cookie used for accessing the deezer api + formats: [ "FLAC", "MP3_320", "MP3_256", "MP3_128", "MP3_64", "AAC_64" ] # the formats you want to use for the deezer tracks. "FLAC", "MP3_320", "MP3_256" & "AAC_64" are only available for premium users and require a valid arl yandexmusic: accessToken: "your access token" # the token used for accessing the yandex music api. See https://github.com/TopiSenpai/LavaSrc#yandex-music playlistLoadLimit: 1 # The number of pages at 100 tracks each diff --git a/application.example.yml b/application.example.yml index a8cac10c..e3566c2c 100644 --- a/application.example.yml +++ b/application.example.yml @@ -33,6 +33,8 @@ plugins: albumLoadLimit: 6 # The number of pages at 300 tracks each deezer: masterDecryptionKey: "your master decryption key" # the master key used for decrypting the deezer tracks. (yes this is not here you need to get it from somewhere else) + arl: "your deezer arl" # the arl cookie used for accessing the deezer api + formats: [ "FLAC", "MP3_320", "MP3_256", "MP3_128", "MP3_64", "AAC_64" ] # the formats you want to use for the deezer tracks. "FLAC", "MP3_320", "MP3_256" & "AAC_64" are only available for premium users and require a valid arl yandexmusic: accessToken: "your access token" # the token used for accessing the yandex music api. See https://github.com/TopiSenpai/LavaSrc#yandex-music playlistLoadLimit: 1 # The number of pages at 100 tracks each 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 8696972b..0549046f 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 @@ -56,15 +56,39 @@ 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 DeezerAudioTrack.TrackFormat[] formats; private final HttpInterfaceManager httpInterfaceManager; private Tokens tokens; public DeezerAudioSourceManager(String masterDecryptionKey) { + this(masterDecryptionKey, null); + } + + public DeezerAudioSourceManager(String masterDecryptionKey, @Nullable String arl) { + this(masterDecryptionKey, arl, null); + } + + public DeezerAudioSourceManager(String masterDecryptionKey, @Nullable String arl, @Nullable DeezerAudioTrack.TrackFormat[] formats) { 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.formats = formats != null && formats.length > 0 ? formats : DeezerAudioTrack.TrackFormat.DEFAULT_FORMATS; + this.httpInterfaceManager = HttpClientTools.createCookielessThreadLocalManager(); + } + + static 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); + } } private void refreshSession() throws IOException { @@ -93,17 +117,6 @@ public Tokens getTokens() throws IOException { return this.tokens; } - static 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); - } - } - @NotNull @Override public String getSourceName() { @@ -398,12 +411,12 @@ private AudioItem getAlbum(String id, boolean preview) throws IOException { } return new DeezerAudioPlaylist(json.get("title").text(), - this.parseTracks(tracks, preview), - DeezerAudioPlaylist.Type.ALBUM, - json.get("link").text(), - artworkUrl, - author, - (int) json.get("nb_tracks").asLong(0)); + this.parseTracks(tracks, preview), + DeezerAudioPlaylist.Type.ALBUM, + json.get("link").text(), + artworkUrl, + author, + (int) json.get("nb_tracks").asLong(0)); } private AudioItem getTrack(String id, boolean preview) throws IOException { @@ -427,12 +440,12 @@ private AudioItem getPlaylist(String id, boolean preview) throws IOException { var tracks = this.getJson(PUBLIC_API_BASE + "/playlist/" + id + "/tracks?limit=10000"); return new DeezerAudioPlaylist(json.get("title").text(), - this.parseTracks(tracks, preview), - DeezerAudioPlaylist.Type.PLAYLIST, - json.get("link").text(), - artworkUrl, - author, - (int) json.get("nb_tracks").asLong(0)); + this.parseTracks(tracks, preview), + DeezerAudioPlaylist.Type.PLAYLIST, + json.get("link").text(), + artworkUrl, + author, + (int) json.get("nb_tracks").asLong(0)); } private AudioItem getArtist(String id, boolean preview) throws IOException { @@ -479,6 +492,15 @@ public String getMasterDecryptionKey() { return this.masterDecryptionKey; } + @Nullable + public String getArl() { + return this.arl; + } + + public DeezerAudioTrack.TrackFormat[] getFormats() { + return this.formats; + } + 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..8472e656 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,42 @@ 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.container.mpeg.MpegAudioTrack; import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager; 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.stream.Collectors; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.function.BiFunction; 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,50 +46,87 @@ 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 static String formatFormats(TrackFormat[] formats) { + var strFormats = new ArrayList(); + for (var format : formats) { + strFormats.add("{\"cipher\":\"BF_CBC_STRIPE\",\"format\":\"" + format.name() + "\"}"); + } + return String.join(",", strFormats); + } - this.checkResponse(json, "Failed to get session ID: "); - var sessionID = json.get("results").get("SESSION").text(); + private JsonBrowser getJsonResponse(HttpUriRequest request, boolean useArl) throws IOException { + try (HttpInterface httpInterface = this.sourceManager.getHttpInterface()) { + httpInterface.getContext().setRequestConfig(RequestConfig.custom().setCookieSpec("standard").build()); + httpInterface.getContext().setCookieStore(cookieStore); - 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); + if (useArl && this.sourceManager.getArl() != null) { + request.setHeader("Cookie", "arl=" + this.sourceManager.getArl()); + } - 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(); + return LavaSrcTools.fetchResponseAsJson(httpInterface, request); + } + } - 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); + private String getSessionId() throws IOException { + var getSessionID = new HttpPost(DeezerAudioSourceManager.PRIVATE_API_BASE + "?method=deezer.ping&input=3&api_version=1.0&api_token="); + var sessionIdJson = this.getJsonResponse(getSessionID, false); - this.checkResponse(json, "Failed to get track token: "); - var trackToken = json.get("results").get("TRACK_TOKEN").text(); + DeezerAudioSourceManager.checkResponse(sessionIdJson, "Failed to get session ID: "); + return sessionIdJson.get("results").get("SESSION").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); + private LicenseToken generateLicenceToken(boolean useArl) throws IOException { + var request = new HttpGet(DeezerAudioSourceManager.PRIVATE_API_BASE + "?method=deezer.getUserData&input=3&api_version=1.0&api_token="); - 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()); + // session ID is not needed with ARL and vice-versa. + if (!useArl || this.sourceManager.getArl() == null) { + request.setHeader("Cookie", "sid=" + this.getSessionId()); + } + + var json = this.getJsonResponse(request, useArl); + DeezerAudioSourceManager.checkResponse(json, "Failed to get user token: "); + + return new LicenseToken( + json.get("results").get("USER").get("OPTIONS").get("license_token").text(), + json.get("results").get("checkForm").text() + ); } - private void checkResponse(JsonBrowser json, String message) throws IllegalStateException { - if (json == null) { - throw new IllegalStateException(message + "No response"); + public SourceWithFormat getSource(boolean useArl, boolean isRetry) throws IOException, URISyntaxException { + var licenseToken = this.generateLicenceToken(useArl); + + var getTrackToken = new HttpPost(DeezerAudioSourceManager.PRIVATE_API_BASE + "?method=song.getData&input=3&api_version=1.0&api_token=" + licenseToken.apiToken); + getTrackToken.setEntity(new StringEntity("{\"sng_id\":\"" + this.trackInfo.identifier + "\"}", ContentType.APPLICATION_JSON)); + var trackTokenJson = this.getJsonResponse(getTrackToken, useArl); + DeezerAudioSourceManager.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(useArl, true); } - 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); + + var trackToken = trackTokenJson.get("results").get("TRACK_TOKEN").text(); + + var getMediaURL = new HttpPost(DeezerAudioSourceManager.MEDIA_BASE + "/get_url"); + getMediaURL.setEntity(new StringEntity("{\"license_token\":\"" + licenseToken.userLicenseToken + "\",\"media\":[{\"type\":\"FULL\",\"formats\":[" + formatFormats(this.sourceManager.getFormats()) + "]}],\"track_tokens\": [\"" + trackToken + "\"]}", ContentType.APPLICATION_JSON)); + + var json = this.getJsonResponse(getMediaURL, useArl); + for (var error : json.get("data").get("errors").values()) { + if (error.get("code").asLong(0) == 2000) { + // error code 2000 = failed to decode track token + return this.getSource(useArl, true); + } } + DeezerAudioSourceManager.checkResponse(json, "Failed to get media URL: "); + + return SourceWithFormat.fromResponse(json, trackTokenJson); } - private byte[] getTrackDecryptionKey() throws NoSuchAlgorithmException { + public byte[] getTrackDecryptionKey() throws NoSuchAlgorithmException { var md5 = Hex.encodeHex(MessageDigest.getInstance("MD5").digest(this.trackInfo.identifier.getBytes()), true); var master_key = this.sourceManager.getMasterDecryptionKey().getBytes(); @@ -94,13 +144,16 @@ 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); - } + return; + } + + var source = this.getSource(this.sourceManager.getArl() != null, false); + try (var stream = new DeezerPersistentHttpStream(httpInterface, source.url, source.contentLength, this.getTrackDecryptionKey())) { + processDelegate(source.format.trackFactory.apply(this.trackInfo, stream), executor); } } } @@ -115,4 +168,76 @@ public AudioSourceManager getSourceManager() { return this.sourceManager; } + public enum TrackFormat { + FLAC(true, FlacAudioTrack::new), + MP3_320(true, Mp3AudioTrack::new), + MP3_256(true, Mp3AudioTrack::new), + MP3_128(false, Mp3AudioTrack::new), + MP3_64(false, Mp3AudioTrack::new), + AAC_64(true, MpegAudioTrack::new); // not sure if this one is so better to be safe. + + private boolean isPremiumFormat; + private BiFunction trackFactory; + + public static final TrackFormat[] DEFAULT_FORMATS = new TrackFormat[]{MP3_128, MP3_64}; + + TrackFormat(boolean isPremiumFormat, BiFunction trackFactory) { + this.isPremiumFormat = isPremiumFormat; + this.trackFactory = trackFactory; + } + + public static TrackFormat from(String format) { + return Arrays.stream(TrackFormat.values()) + .filter(it -> it.name().equals(format)) + .findFirst() + .orElse(null); + } + } + + private static class LicenseToken { + private final String userLicenseToken; + private final String apiToken; + + private LicenseToken(String userLicenseToken, String apiToken) { + this.userLicenseToken = userLicenseToken; + this.apiToken = apiToken; + } + } + + public static class SourceWithFormat { + private final URI url; + private final TrackFormat format; + private final long contentLength; + + private SourceWithFormat(String url, TrackFormat format, long contentLength) throws URISyntaxException { + this.url = new URI(url); + this.format = format; + this.contentLength = contentLength; + } + + private static SourceWithFormat fromResponse(JsonBrowser json, JsonBrowser trackJson) throws URISyntaxException { + var media = json.get("data").index(0).get("media").index(0); + if (media.isNull()) { + return null; + } + + var format = media.get("format").text(); + var url = media.get("sources").index(0).get("url").text(); + var contentLength = trackJson.get("results").get("FILESIZE_" + format).asLong(Units.CONTENT_LENGTH_UNKNOWN); + return new SourceWithFormat(url, TrackFormat.from(format), contentLength); + } + + public URI getUrl() { + return this.url; + } + + public TrackFormat getFormat() { + return this.format; + } + + public long getContentLength() { + return this.contentLength; + } + + } } 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..806dcd47 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 @@ -1,5 +1,6 @@ package com.github.topi314.lavasrc.plugin; +import com.github.topi314.lavasrc.deezer.DeezerAudioTrack; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -8,6 +9,8 @@ public class DeezerConfig { private String masterDecryptionKey; + private String arl; + private DeezerAudioTrack.TrackFormat[] formats; public String getMasterDecryptionKey() { return this.masterDecryptionKey; @@ -17,4 +20,20 @@ public void setMasterDecryptionKey(String masterDecryptionKey) { this.masterDecryptionKey = masterDecryptionKey; } + public String getArl() { + return this.arl; + } + + public void setArl(String arl) { + this.arl = arl; + } + + public DeezerAudioTrack.TrackFormat[] getFormats() { + return this.formats; + } + + public void setFormats(DeezerAudioTrack.TrackFormat[] formats) { + this.formats = formats; + } + } 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 3cc28399..bab57f43 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 @@ -60,7 +60,7 @@ public LavaSrcPlugin(LavaSrcConfig pluginConfig, SourcesConfig sourcesConfig, Ly } } if (sourcesConfig.isDeezer() || lyricsSourcesConfig.isDeezer()) { - this.deezer = new DeezerAudioSourceManager(deezerConfig.getMasterDecryptionKey()); + this.deezer = new DeezerAudioSourceManager(deezerConfig.getMasterDecryptionKey(), deezerConfig.getArl(), deezerConfig.getFormats()); } if (sourcesConfig.isYandexMusic() || lyricsSourcesConfig.isYandexMusic()) { this.yandexMusic = new YandexMusicSourceManager(yandexMusicConfig.getAccessToken());