Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for FLAC streaming. #178

Merged
merged 4 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,21 @@ 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
Expand Down Expand Up @@ -357,6 +364,11 @@ public String getMasterDecryptionKey() {
return this.masterDecryptionKey;
}

@Nullable
public String getArl() {
return this.arl;
}

public HttpInterface getHttpInterface() {
return this.httpInterfaceManager.getInterface();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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\":\"" + (tryFlac ? "FLAC" : "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);
Expand All @@ -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);
}
}
}
Expand All @@ -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<AudioTrackInfo, PersistentHttpStream, InternalAudioTrack> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down