Skip to content

Commit

Permalink
add apple music auto token extraction (#242)
Browse files Browse the repository at this point in the history
  • Loading branch information
Nansess authored Dec 19, 2024
1 parent 6a171d7 commit 3112a3d
Show file tree
Hide file tree
Showing 2 changed files with 127 additions and 46 deletions.
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) {
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 RuntimeException("Failed to initialize token manager", 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,16 @@ public AudioSearchResult getSearchSuggestions(String query, Set<AudioSearchResul
}
}
}

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

public JsonBrowser getJson(String uri) throws IOException {
var token = this.tokenManager.getToken();

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 " + token.apiToken);
if (token.origin != null && !token.origin.isEmpty()) {
request.addHeader("Origin", "https://" + token.origin);
}
return LavaSrcTools.fetchResponseAsJson(this.httpInterfaceManager.getInterface(), request);
}
Expand All @@ -319,7 +289,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 +436,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 +482,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,113 @@
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 java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.regex.Pattern;

public class AppleMusicTokenManager {

private static final Pattern TOKEN_PATTERN = Pattern.compile("ey[\\w-]+\\.[\\w-]+\\.[\\w-]+");

private Token token;

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

public Token getToken() throws IOException {
if (this.token.isExpired()) {
this.fetchNewToken();
}
return this.token;
}

public void setToken(String mediaAPIToken) throws IOException {
this.parseTokenData(mediaAPIToken);
}

private void parseTokenData(String mediaAPIToken) throws IOException {
if (mediaAPIToken == null || mediaAPIToken.isEmpty()) {
throw new IllegalArgumentException("Invalid token provided.");
}

var parts = mediaAPIToken.split("\\.");
if (parts.length < 3) {
throw new IllegalArgumentException("Invalid token provided must have 3 parts separated by '.'");
}

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

this.token = new Token(mediaAPIToken, json.get("root_https_origin").index(0).text(), Instant.ofEpochSecond(json.get("exp").asLong(0)));
}

private void fetchNewToken() throws IOException {
try (var httpClient = HttpClients.createDefault()) {
var mainPageHtml = fetchHtml(httpClient, "https://music.apple.com");
var tokenScriptUrl = extractTokenScriptUrl(mainPageHtml);

if (tokenScriptUrl == null) {
throw new IllegalStateException("Failed to locate token script URL.");
}

var tokenScriptContent = fetchHtml(httpClient, tokenScriptUrl);
var tokenMatcher = TOKEN_PATTERN.matcher(tokenScriptContent);

if (!tokenMatcher.find()) {
throw new IllegalStateException("Failed to extract token from script content.");
}
this.parseTokenData(tokenMatcher.group());
}
}

private String fetchHtml(CloseableHttpClient httpClient, String url) throws IOException {
var 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) {
var 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."));
}

public static class Token {
public final String apiToken;
public final String origin;
public final Instant expire;

public Token(String apiToken, String origin, Instant expire) {
this.apiToken = apiToken;
this.origin = origin;
this.expire = expire;
}

private boolean isExpired() {
if (this.apiToken == null || this.expire == null) {
return true;
}
return expire.minusSeconds(5).isBefore(Instant.now());
}
}
}

0 comments on commit 3112a3d

Please sign in to comment.