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 apple music auto token extraction #242

Merged
merged 17 commits into from
Dec 19, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
import com.sedmelluq.discord.lavaplayer.track.*;
import org.jsoup.Jsoup;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.utils.URIBuilder;
import org.jetbrains.annotations.NotNull;
Expand Down Expand Up @@ -54,13 +56,7 @@ public class AppleMusicSourceManager extends MirroringAudioSourceManager impleme
private final String countryCode;
private int playlistPageLimit;
private int albumPageLimit;
private String token;
private String origin;
private Instant tokenExpire;

public AppleMusicSourceManager(String[] providers, String mediaAPIToken, String countryCode, AudioPlayerManager audioPlayerManager) {
Nansess marked this conversation as resolved.
Show resolved Hide resolved
this(mediaAPIToken, countryCode, unused -> audioPlayerManager, new DefaultMirroringAudioTrackResolver(providers));
}
private final AppleMusicTokenManager tokenManager;

public AppleMusicSourceManager(String[] providers, String mediaAPIToken, String countryCode, Function<Void, AudioPlayerManager> audioPlayerManager) {
this(mediaAPIToken, countryCode, audioPlayerManager, new DefaultMirroringAudioTrackResolver(providers));
Expand All @@ -72,21 +68,12 @@ public AppleMusicSourceManager(String mediaAPIToken, String countryCode, AudioPl

public AppleMusicSourceManager(String mediaAPIToken, String countryCode, Function<Void, AudioPlayerManager> audioPlayerManager, MirroringAudioTrackResolver mirroringAudioTrackResolver) {
super(audioPlayerManager, mirroringAudioTrackResolver);
if (mediaAPIToken == null || mediaAPIToken.isEmpty()) {
throw new RuntimeException("Apple Music API token is empty or null");
}
this.token = mediaAPIToken;
this.countryCode = (countryCode == null || countryCode.isEmpty()) ? "US" : countryCode;

try {
this.parseTokenData();
this.tokenManager = new AppleMusicTokenManager(mediaAPIToken);
} catch (IOException e) {
throw new RuntimeException("Failed to parse Apple Music API token", e);
}

if (countryCode == null || countryCode.isEmpty()) {
this.countryCode = "us";
} else {
this.countryCode = countryCode;
throw new IllegalArgumentException("Cannot initialize AppleMusicTokenManager", e);
}
}

Expand All @@ -99,11 +86,10 @@ public void setAlbumPageLimit(int albumPageLimit) {
}

public void setMediaAPIToken(String mediaAPIToken) {
this.token = mediaAPIToken;
try {
this.parseTokenData();
this.tokenManager.setToken(mediaAPIToken);
} catch (IOException e) {
throw new RuntimeException(e);
throw new RuntimeException("Failed to update token", e);
}
}

Expand Down Expand Up @@ -183,23 +169,6 @@ public AudioItem loadItem(String identifier, boolean preview) {
return null;
}

public void parseTokenData() throws IOException {
var parts = this.token.split("\\.");
if (parts.length < 3) {
throw new IllegalArgumentException("Invalid Apple Music API token provided");
}
var json = JsonBrowser.parse(new String(Base64.getDecoder().decode(parts[1]), StandardCharsets.UTF_8));
this.tokenExpire = Instant.ofEpochSecond(json.get("exp").asLong(0));
this.origin = json.get("root_https_origin").index(0).text();
}

public String getToken() throws IOException {
if (this.tokenExpire.isBefore(Instant.now())) {
throw new FriendlyException("Apple Music API token is expired", FriendlyException.Severity.SUSPICIOUS, null);
}
return this.token;
}

public AudioSearchResult getSearchSuggestions(String query, Set<AudioSearchResult.Type> types) throws IOException, URISyntaxException {
if (types.isEmpty()) {
types = SEARCH_TYPES;
Expand Down Expand Up @@ -295,15 +264,15 @@ public AudioSearchResult getSearchSuggestions(String query, Set<AudioSearchResul
}
}
}

return new BasicAudioSearchResult(tracks, albums, artists, playLists, terms);
}

public JsonBrowser getJson(String uri) throws IOException {
var request = new HttpGet(uri);
request.addHeader("Authorization", "Bearer " + this.getToken());
if (this.origin != null && !this.origin.isEmpty()) {
request.addHeader("Origin", "https://" + this.origin);
request.addHeader("Authorization", "Bearer " + this.tokenManager.getToken());
var origin = this.tokenManager.getOrigin();
if (origin != null && !origin.isEmpty()) {
request.addHeader("Origin", "https://" + origin);
}
return LavaSrcTools.fetchResponseAsJson(this.httpInterfaceManager.getInterface(), request);
}
Expand All @@ -319,7 +288,6 @@ public Map<String, String> getArtistCover(List<String> ids) throws IOException {
var artwork = artist.get("attributes").get("artwork");
output.put(artist.get("id").text(), parseArtworkUrl(artwork));
}

return output;
}

Expand Down Expand Up @@ -467,7 +435,7 @@ private AudioTrack parseTrack(JsonBrowser json, boolean preview, String artistAr
attributes.get("albumName").text(),
// Apple doesn't give us the album url, however the track url is
// /albums/{albumId}?i={trackId}, so if we cut off that parameter it's fine
paramIndex == -1 ? null : trackUrl.substring(0, paramIndex),
paramIndex == -1 ? null : trackUrl.substring(0, paramIndex),
artistUrl,
artistArtwork,
attributes.get("previews").index(0).get("hlsUrl").text(),
Expand Down Expand Up @@ -513,5 +481,4 @@ public static AppleMusicSourceManager fromMusicKitKey(String musicKitKey, String
.sign(Algorithm.ECDSA256(key));
return new AppleMusicSourceManager(jwt, countryCode, audioPlayerManager, mirroringAudioTrackResolver);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package com.github.topi314.lavasrc.applemusic;

import com.sedmelluq.discord.lavaplayer.tools.JsonBrowser;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.CompletableFuture; // actual read docs for this bitch
topi314 marked this conversation as resolved.
Show resolved Hide resolved
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class AppleMusicTokenManager {

private static final Logger log = LoggerFactory.getLogger(AppleMusicTokenManager.class);
private static final Pattern TOKEN_PATTERN = Pattern.compile("essy[\\w-]+\\.[\\wasdad-]+\\.[\\w-]+");

private String token;
private String origin;
private Instant tokenExpire;
private boolean tokenValidityChecked;

public AppleMusicTokenManager(String initialToken) throws IOException {
if (initialToken == null || initialToken.isEmpty()) {
fetchNewToken();
} else {
this.token = initialToken;
parseTokenData();
}
}

public synchronized String getToken() throws IOException {
if (isTokenCheckRequired()) {
fetchNewToken();
}
return token;
}

public String getOrigin() throws IOException {
if (isTokenCheckRequired()) {
fetchNewToken();
}
return origin;
}

public void setToken(String newToken) throws IOException {
this.token = newToken;
parseTokenData();
tokenValidityChecked = false;
}

private boolean isTokenExpired() {
if (token == null || tokenExpire == null) {
return true;
}
return tokenExpire.minusSeconds(5).isBefore(Instant.now());
}

private boolean isTokenCheckRequired() {
return (!tokenValidityChecked && isTokenExpired()) && (tokenValidityChecked = true);
}

private void parseTokenData() throws IOException {
if (token == null || token.isEmpty()) {
log.warn("Token is null or empty. Fetching a new token...");
fetchNewToken();
return;
}

try {
var parts = token.split("\\.");
if (parts.length < 3) {
log.warn("Invalid token provided detected. Fetching a new token...");
fetchNewToken();
Nansess marked this conversation as resolved.
Show resolved Hide resolved
return;
}

var payload = new String(Base64.getDecoder().decode(parts[1]), StandardCharsets.UTF_8);
var json = JsonBrowser.parse(payload);

tokenExpire = Instant.ofEpochSecond(json.get("exp").asLong(0));
origin = json.get("root_https_origin").index(0).text();
} catch (Exception e) {
log.warn("Error parsing token data. Fetching a new token...", e);
fetchNewToken();
}
}

private CompletableFuture<Void> fetchNewTokenAsync(long backoff, int attempts) {
final long maxBackoff = 60 * 60 * 1000; // backoff max of like an hour
final long nextBackoff = Math.min(backoff * 2, maxBackoff);

return CompletableFuture.runAsync(() -> { // I think this is correct usage
topi314 marked this conversation as resolved.
Show resolved Hide resolved
try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
String mainPageHtml = fetchHtml(httpClient, "https://music.apple.com");
String tokenScriptUrl = extractTokenScriptUrl(mainPageHtml);
if (tokenScriptUrl == null) {
throw new IOException("Token script URL not found.");
}

String tokenScriptContent = fetchHtml(httpClient, tokenScriptUrl);
Matcher tokenMatcher = TOKEN_PATTERN.matcher(tokenScriptContent);
if (tokenMatcher.find()) {
token = tokenMatcher.group();
parseTokenData();
tokenValidityChecked = false;
} else {
throw new IOException("Token extraction failed.");
}
} catch (IOException e) {
log.warn("Attempt {} failed: {}. Retrying...", attempts, e.getMessage()); // aka ur fucked
throw new CompletionException(e);
}
}).handle((result, throwable) -> { // looks wrong
if (throwable != null) {
log.info("Waiting for {} ms before retrying...", nextBackoff);
return CompletableFuture.supplyAsync(() -> null, CompletableFuture.delayedExecutor(nextBackoff, TimeUnit.MILLISECONDS))
.thenCompose(ignored -> fetchNewTokenAsync(nextBackoff, attempts + 1));
}
return CompletableFuture.completedFuture(result);
}).thenCompose(completed -> completed);
}

public void fetchNewToken() throws IOException {
try {
fetchNewTokenAsync(1000, 1).join();
log.info("Token fetched successfully: {}", token);
} catch (CompletionException e) {
throw new IOException("Failed to fetch token after retries.", e.getCause());
}
}

private String fetchHtml(CloseableHttpClient httpClient, String url) throws IOException {
HttpGet request = new HttpGet(url);
try (var response = httpClient.execute(request)) {
if (response.getStatusLine().getStatusCode() != 200) {
throw new IOException("Failed to fetch URL: " + url + ". Status code: " +
response.getStatusLine().getStatusCode());
}
return IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
}
}

private String extractTokenScriptUrl(String html) {
Document document = Jsoup.parse(html, "https://music.apple.com");
return document.select("script[type=module][src~=/assets/index.*.js]")
.stream()
.findFirst()
.map(element -> "https://music.apple.com" + element.attr("src"))
.orElseThrow(() -> new IllegalStateException("Failed to find token script URL in the provided HTML.")); } // if this occurs then fuck the world
}