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

Yandex Music Updates #197

Merged
merged 5 commits into from
May 16, 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
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