Skip to content

Commit

Permalink
Yandex Music Updates (#197)
Browse files Browse the repository at this point in the history
  • Loading branch information
Krispeckt authored May 16, 2024
1 parent c8f9c40 commit 20403f0
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 66 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ plugins:
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)
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
albumLoadLimit: 1 # The number of pages at 50 tracks each
artistLoadLimit: 1 # The number of pages at 10 tracks each
flowerytts:
voice: "default voice" # (case-sensitive) get default voice from here https://api.flowery.pw/v1/tts/voices
translate: false # whether to translate the text to the native language of voice
Expand Down Expand Up @@ -334,6 +337,7 @@ LavaSrc adds the following fields to tracks & playlists in Lavalink

### Yandex Music
* `ymsearch:animals architects`
* `ymrec:71663565` (`ymrec:identifier`, you can get the identifier from Lavalink's "info.identifier" response to Yandex music loadtracks)
* https://music.yandex.ru/album/13886032/track/71663565
* https://music.yandex.ru/album/13886032
* https://music.yandex.ru/track/71663565
Expand Down
3 changes: 3 additions & 0 deletions application.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ plugins:
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)
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
albumLoadLimit: 1 # The number of pages at 50 tracks each
artistLoadLimit: 1 # The number of pages at 10 tracks each
flowerytts:
voice: "default voice" # (case-sensitive) get default voice here https://flowery.pw/docs/flowery/tts-voices-v-1-tts-voices-get
translate: false # whether to translate the text to the native language of voice
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,23 @@
import java.util.stream.Collectors;

public class YandexMusicSourceManager extends ExtendedAudioSourceManager implements HttpConfigurable {
public static final Pattern URL_PATTERN = Pattern.compile("(https?://)?music\\.yandex\\.(ru|com)/(?<type1>artist|album|track)/(?<identifier>[0-9]+)(/(?<type2>track)/(?<identifier2>[0-9]+))?/?");
public static final Pattern URL_PLAYLIST_PATTERN = Pattern.compile("(https?://)?music\\.yandex\\.(ru|com)/users/(?<identifier>[0-9A-Za-z@.-]+)/playlists/(?<identifier2>[0-9]+)/?");
public static final Pattern URL_PATTERN = Pattern.compile("(https?://)?music\\.yandex\\.(?<domain>ru|com|kz|by)/(?<type1>artist|album|track)/(?<identifier>[0-9]+)(/(?<type2>track)/(?<identifier2>[0-9]+))?/?");
public static final Pattern URL_PLAYLIST_PATTERN = Pattern.compile("(https?://)?music\\.yandex\\.(?<domain>ru|com|kz|by)/users/(?<identifier>[0-9A-Za-z@.-]+)/playlists/(?<identifier2>[0-9]+)/?");
public static final String SEARCH_PREFIX = "ymsearch:";
public static final String RECOMMENDATIONS_PREFIX = "ymrec:";
public static final String PUBLIC_API_BASE = "https://api.music.yandex.net";
public static final int ARTIST_MAX_PAGE_ITEMS = 10;
public static final int PLAYLIST_MAX_PAGE_ITEMS = 100;
public static final int ALBUM_MAX_PAGE_ITEMS = 50;

private static final Logger log = LoggerFactory.getLogger(YandexMusicSourceManager.class);

private final HttpInterfaceManager httpInterfaceManager;

private final String accessToken;
private int artistLoadLimit;
private int albumLoadLimit;
private int playlistLoadLimit;

public YandexMusicSourceManager(String accessToken) {
if (accessToken == null || accessToken.isEmpty()) {
Expand All @@ -47,6 +54,16 @@ public YandexMusicSourceManager(String accessToken) {
this.httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager();
}

public void setArtistLoadLimit(int artistLimit) {
this.artistLoadLimit = artistLimit;
}
public void setAlbumLoadLimit(int albumLimit) {
this.albumLoadLimit = albumLimit;
}
public void setPlaylistLoadLimit(int playlistLimit) {
this.playlistLoadLimit = playlistLimit;
}

@Override
public String getSourceName() {
return "yandexmusic";
Expand All @@ -59,58 +76,97 @@ public AudioItem loadItem(AudioPlayerManager manager, AudioReference reference)
return this.getSearch(reference.identifier.substring(SEARCH_PREFIX.length()));
}

if (reference.identifier.startsWith(RECOMMENDATIONS_PREFIX)) {
return this.getRecommendations(reference.identifier.substring(RECOMMENDATIONS_PREFIX.length()));
}

var matcher = URL_PATTERN.matcher(reference.identifier);
if (matcher.find()) {
var domainEnd = matcher.group("domain");
switch (matcher.group("type1")) {
case "album":
if (matcher.group("type2") != null) {
var trackId = matcher.group("identifier2");
return this.getTrack(trackId);
return this.getTrack(trackId, domainEnd);
}
var albumId = matcher.group("identifier");
return this.getAlbum(albumId);
return this.getAlbum(albumId, domainEnd);
case "artist":
var artistId = matcher.group("identifier");
return this.getArtist(artistId);
return this.getArtist(artistId, domainEnd);
case "track":
var trackId = matcher.group("identifier");
return this.getTrack(trackId);
return this.getTrack(trackId, domainEnd);
}
return null;
}
matcher = URL_PLAYLIST_PATTERN.matcher(reference.identifier);
if (matcher.find()) {
var userId = matcher.group("identifier");
var playlistId = matcher.group("identifier2");
return this.getPlaylist(userId, playlistId);
return this.getPlaylist(userId, playlistId, matcher.group("domain"));
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return null;
}

private static boolean canBeLong(String str) {
try {
Long.parseLong(str);
return true;
} catch(NumberFormatException e) {
return false;
}
}

private AudioItem getRecommendations(String identifier) throws IOException {
if (!canBeLong(identifier)) {
throw new IllegalArgumentException("The yandex music track identifier must be a number");
}

var json = this.getJson(PUBLIC_API_BASE + "/tracks/"+identifier+"/similar");
if (json.isNull() || json.get("result").isNull() || json.get("result").get("similarTracks").values().isEmpty()) {
return AudioReference.NO_TRACK;
}
var tracks = this.parseTracks(json.get("result").get("similarTracks"), "com");
if (tracks.isEmpty()) {
return AudioReference.NO_TRACK;
}
var trackInfo = json.get("result").get("track");
return new YandexMusicAudioPlaylist(
"Yandex Music Recommendations For Track: " + trackInfo.get("title").text(),
tracks,
ExtendedAudioPlaylist.Type.RECOMMENDATIONS,
null,
null,
null,
tracks.size()
);
}

private AudioItem getSearch(String query) throws IOException {
var json = this.getJson(PUBLIC_API_BASE + "/search?text=" + URLEncoder.encode(query, StandardCharsets.UTF_8) + "&type=track&page=0");
if (json.isNull() || json.get("result").get("tracks").isNull()) {
return AudioReference.NO_TRACK;
}
var tracks = this.parseTracks(json.get("result").get("tracks").get("results"));
var tracks = this.parseTracks(json.get("result").get("tracks").get("results"), "com");
if (tracks.isEmpty()) {
return AudioReference.NO_TRACK;
}
return new BasicAudioPlaylist("Yandex Music Search: " + query, tracks, null, true);
}

private AudioItem getAlbum(String id) throws IOException {
var json = this.getJson(PUBLIC_API_BASE + "/albums/" + id + "/with-tracks");
private AudioItem getAlbum(String id, String domainEnd) throws IOException {
var json = this.getJson(PUBLIC_API_BASE + "/albums/" + id + "/with-tracks?page-size=" + ALBUM_MAX_PAGE_ITEMS * albumLoadLimit);
if (json.isNull() || json.get("result").isNull()) {
return AudioReference.NO_TRACK;
}
var tracks = new ArrayList<AudioTrack>();
for (var volume : json.get("result").get("volumes").values()) {
for (var track : volume.values()) {
var parsedTrack = this.parseTrack(track);
var parsedTrack = this.parseTrack(track, domainEnd);
if (parsedTrack != null) {
tracks.add(parsedTrack);
}
Expand All @@ -119,34 +175,33 @@ private AudioItem getAlbum(String id) throws IOException {
if (tracks.isEmpty()) {
return AudioReference.NO_TRACK;
}
var coverUri = json.get("result").get("coverUri").text();
var author = json.get("result").get("artists").values().get(0).get("name").text();
return new YandexMusicAudioPlaylist(
json.get("result").get("title").text(),
tracks,
ExtendedAudioPlaylist.Type.ALBUM,
"https://music.yandex.ru/album/" + id,
this.formatCoverUri(coverUri),
"https://music.yandex." + domainEnd + "/album/" + id,
this.parseCoverUri(json.get("result")),
author,
tracks.size()
);
}

private AudioItem getTrack(String id) throws IOException {
private AudioItem getTrack(String id, String domainEnd) throws IOException {
var json = this.getJson(PUBLIC_API_BASE + "/tracks/" + id);
if (json.isNull() || json.get("result").values().get(0).get("available").text().equals("false")) {
return AudioReference.NO_TRACK;
}
return this.parseTrack(json.get("result").values().get(0));
return this.parseTrack(json.get("result").values().get(0), domainEnd);
}

private AudioItem getArtist(String id) throws IOException {
var json = this.getJson(PUBLIC_API_BASE + "/artists/" + id + "/tracks?page-size=10");
private AudioItem getArtist(String id, String domainEnd) throws IOException {
var json = this.getJson(PUBLIC_API_BASE + "/artists/" + id + "/tracks?page-size=" + ARTIST_MAX_PAGE_ITEMS * artistLoadLimit);
if (json.isNull() || json.get("result").values().isEmpty()) {
return AudioReference.NO_TRACK;
}

var tracks = this.parseTracks(json.get("result").get("tracks"));
var tracks = this.parseTracks(json.get("result").get("tracks"), domainEnd);
if (tracks.isEmpty()) {
return AudioReference.NO_TRACK;
}
Expand All @@ -155,41 +210,27 @@ private AudioItem getArtist(String id) throws IOException {
var artistJson = artistJsonResponse.get("result").get("artist");
var author = artistJson.get("name").text();

String coverUri = null;

if (!artistJson.get("ogImage").isNull()) {
coverUri = this.formatCoverUri(artistJson.get("ogImage").text());
} else if (!artistJson.get("cover").isNull()) {
coverUri = this.formatCoverUri(artistJson.get("cover").get("uri").text());
}

return new YandexMusicAudioPlaylist(
author + "'s Top Tracks",
tracks,
ExtendedAudioPlaylist.Type.ARTIST,
"https://music.yandex.ru/artist/" + id,
coverUri,
parseCoverUri(artistJson),
author,
tracks.size()
);
}

private AudioItem getPlaylist(String userString, String id) throws IOException {
var json = this.getJson(PUBLIC_API_BASE + "/users/" + userString + "/playlists/" + id);
private AudioItem getPlaylist(String userString, String id, String domainEnd) throws IOException {
var json = this.getJson(
PUBLIC_API_BASE + "/users/" + userString + "/playlists/" + id
+ "?page-size=" + PLAYLIST_MAX_PAGE_ITEMS * playlistLoadLimit
+ "&rich-tracks=true"
);
if (json.isNull() || json.get("result").isNull() || json.get("result").get("tracks").values().isEmpty()) {
return AudioReference.NO_TRACK;
}
var tracks = new ArrayList<AudioTrack>();
var tracksToParse = json.get("result").get("tracks").values();
if (tracksToParse.get(0).get("track").isNull()) {
tracksToParse = getTracks(getTrackIds(tracksToParse));
}
for (var track : tracksToParse) {
var parsedTrack = track.get("track").isNull() ? this.parseTrack(track) : this.parseTrack(track.get("track"));
if (parsedTrack != null) {
tracks.add(parsedTrack);
}
}
var tracks = this.parseTracks(json.get("result").get("tracks"), domainEnd);
if (tracks.isEmpty()) {
return AudioReference.NO_TRACK;
}
Expand All @@ -201,29 +242,18 @@ private AudioItem getPlaylist(String userString, String id) throws IOException {
} else {
playlistTitle = json.get("result").get("title").text();
}
var coverUri = json.get("result").get("cover").get("uri").text();
var author = json.get("result").get("owner").get("name").text();
return new YandexMusicAudioPlaylist(
playlistTitle,
tracks,
ExtendedAudioPlaylist.Type.PLAYLIST,
"https://music.yandex.ru/users/" + userString + "/playlists/" + id,
this.formatCoverUri(coverUri),
"https://music.yandex." + domainEnd + "/users/" + userString + "/playlists/" + id,
this.parseCoverUri(json.get("result")),
author,
tracks.size()
);
}

private List<JsonBrowser> getTracks(String trackIds) throws IOException {
return getJson(PUBLIC_API_BASE + "/tracks?track-ids=" + URLEncoder.encode(trackIds, StandardCharsets.UTF_8)).get("result").values();
}

private String getTrackIds(List<JsonBrowser> tracksToParse) {
return tracksToParse.stream()
.map(node -> node.get("id").text())
.collect(Collectors.joining(","));
}

public JsonBrowser getJson(String uri) throws IOException {
var request = new HttpGet(uri);
request.setHeader("Accept", "application/json");
Expand All @@ -238,41 +268,39 @@ public String getDownloadStrings(String uri) throws IOException {
return HttpClientTools.fetchResponseLines(this.httpInterfaceManager.getInterface(), request, "downloadinfo-xml-page")[0];
}

private List<AudioTrack> parseTracks(JsonBrowser json) {
private List<AudioTrack> parseTracks(JsonBrowser json, String domainEnd) {
var tracksToParse = json.values();
var tracks = new ArrayList<AudioTrack>();
for (var track : json.values()) {
var parsedTrack = this.parseTrack(track);
for (var track : tracksToParse) {
var parsedTrack = track.get("track").isNull() ? this.parseTrack(track, domainEnd) : this.parseTrack(track.get("track"), domainEnd);
if (parsedTrack != null) {
tracks.add(parsedTrack);
}
}
return tracks;
}

private AudioTrack parseTrack(JsonBrowser json) {
private AudioTrack parseTrack(JsonBrowser json, String domainEnd) {
if (!json.get("available").asBoolean(false)) {
return null;
}
var id = json.get("id").text();
var artist = parseArtist(json);
var coverUri = json.get("coverUri").text();

String albumName = null;
String albumUrl = null;
if (!json.get("albums").values().isEmpty()) {
var album = json.get("albums").values().get(0);
albumName = album.get("title").text();
albumUrl = "https://music.yandex.ru/album/" + album.get("id").text();
albumUrl = "https://music.yandex." + domainEnd + "/album/" + album.get("id").text();
}

String artistUrl = null;
String artistArtworkUrl = null;
if (!json.get("artists").values().isEmpty()) {
var firstArtist = json.get("artists").values().get(0);
artistUrl = "https://music.yandex.ru/artist/" + firstArtist.get("id").text();
if (!firstArtist.get("cover").isNull()) {
artistArtworkUrl = this.formatCoverUri(firstArtist.get("cover").get("uri").text());
}
artistUrl = "https://music.yandex." + domainEnd + "/artist/" + firstArtist.get("id").text();
artistArtworkUrl = parseCoverUri(firstArtist);
}
return new YandexMusicAudioTrack(
new AudioTrackInfo(
Expand All @@ -281,8 +309,8 @@ private AudioTrack parseTrack(JsonBrowser json) {
json.get("durationMs").as(Long.class),
id,
false,
"https://music.yandex.ru/track/" + id,
this.formatCoverUri(coverUri),
"https://music.yandex." + domainEnd + "/track/" + id,
this.parseCoverUri(json),
null
),
albumName,
Expand Down Expand Up @@ -315,6 +343,26 @@ private String extractArtists(JsonBrowser artistNode) {
.collect(Collectors.joining(", "));
}

private String parseCoverUri(JsonBrowser objectJson) {
if (!objectJson.get("ogImage").isNull()) {
return formatCoverUri(objectJson.get("ogImage").text());
}
if (!objectJson.get("coverUri").isNull()) {
return formatCoverUri(objectJson.get("coverUri").text());
}

var coverJson = objectJson.get("cover");
if (!coverJson.isNull()) {
if (!coverJson.get("uri").isNull()) {
return formatCoverUri(coverJson.get("uri").text());
} else if (!coverJson.get("itemsUri").values().isEmpty()) {
return formatCoverUri(coverJson.get("itemsUri").values().get(0).text());
}
}

return null;
}

private String formatCoverUri(String coverUri) {
return coverUri != null ? "https://" + coverUri.replace("%%", "400x400") : null;
}
Expand Down
Loading

0 comments on commit 20403f0

Please sign in to comment.