diff --git a/README.md b/README.md
index 57e5b0b6..6772b741 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,14 @@
-[![](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Fmaven.topi.wtf%2Freleases%2Fcom%2Fgithub%2FTopiSenpai%2FLavaSrc%2Flavasrc%2Fmaven-metadata.xml)](https://maven.topi.wtf/#/releases/com/github/TopiSenpai/LavaSrc/lavasrc)
+[![](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Fmaven.topi.wtf%2Freleases%2Fcom%2Fgithub%2Ftopi314%2FLavaSrc%2Flavasrc%2Fmaven-metadata.xml)](https://maven.topi.wtf/#/releases/com/github/topi314/LavaSrc/lavasrc)
# LavaSrc
-A collection of additional [Lavaplayer v2](https://github.com/sedmelluq/lavaplayer) & [LavaSearch](https://github.com/topi314/LavaSearch) Audio Source Managers and [Lavalink v4](https://github.com/lavalink-devs/Lavalink) Plugin.
-* [Spotify*](https://www.spotify.com) playlists/albums/songs/artists(top tracks)/search results
-* [Apple Music*](https://www.apple.com/apple-music/) playlists/albums/songs/artists/search results(Big thx to [ryan5453](https://github.com/ryan5453) for helping me)
-* [Deezer](https://www.deezer.com) playlists/albums/songs/artists/search results(Big thx to [ryan5453](https://github.com/ryan5453) and [melike2d](https://github.com/melike2d) for helping me)
+A collection of additional [Lavaplayer v2](https://github.com/sedmelluq/lavaplayer), [LavaSearch](https://github.com/topi314/LavaSearch) & [LavaLyrics](https://github.com/topi314/LavaLyrics) Audio Source Managers and [Lavalink v4](https://github.com/lavalink-devs/Lavalink) Plugin.
+* [Spotify*](https://www.spotify.com) playlists/albums/songs/artists(top tracks)/search results/[LavaSearch](https://github.com/topi314/LavaSearch)/[LavaLyrics](https://github.com/topi314/LavaLyrics)
+* [Apple Music*](https://www.apple.com/apple-music/) playlists/albums/songs/artists/search results/[LavaSearch](https://github.com/topi314/LavaSearch)(Big thx to [ryan5453](https://github.com/ryan5453) for helping me)
+* [Deezer](https://www.deezer.com) playlists/albums/songs/artists/search results/[LavaSearch](https://github.com/topi314/LavaSearch)/[LavaLyrics](https://github.com/topi314/LavaLyrics)(Big thx to [ryan5453](https://github.com/ryan5453) and [melike2d](https://github.com/melike2d) for helping me)
* [Yandex Music](https://music.yandex.ru) playlists/albums/songs/artists/podcasts/search results(Thx to [AgutinVBoy](https://github.com/agutinvboy) for implementing it)
* [Flowery TTS](https://flowery.pw/docs/flowery/synthesize-v-1-tts-get) (Thx to [bachtran02](https://github.com/bachtran02) for implementing it)
-* [YouTube](https://youtube.com), [YouTubeMusic](https://music.youtube.com/), [Deezer](https://www.deezer.com), [Spotify](https://www.spotify.com) & [AppleMusic](https://www.apple.com/apple-music/) support for [LavaSearch](https://github.com/topi314/LavaSearch) (Thx to [DRSchlaubi](https://github.com/DRSchlaubi) for helping me)
+* [YouTube](https://youtube.com) & [YouTubeMusic](https://music.youtube.com/) [LavaSearch](https://github.com/topi314/LavaSearch)/[LavaLyrics](https://github.com/topi314/LavaLyrics) (Thx to [DRSchlaubi](https://github.com/DRSchlaubi) for helping me)
`*tracks are searched & played via YouTube or other configurable sources`
@@ -87,10 +87,19 @@ To get a Spotify clientId & clientSecret you must go [here](https://developer.sp
```java
AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
-// create a new SpotifySourceManager with the default providers, clientId, clientSecret, countryCode and AudioPlayerManager and register it
-playerManager.registerSourceManager(new SpotifySourceManager(null, clientId, clientSecret, countryCode, playerManager));
+// create a new SpotifySourceManager with the default providers, clientId, clientSecret, spDc, countryCode and AudioPlayerManager and register it
+playerManager.registerSourceManager(new SpotifySourceManager(null, clientId, clientSecret, spDc, countryCode, playerManager));
```
+
+How to get sp dc cookie
+
+1. Go to https://open.spotify.com
+2. Open DevTools and go to the Application tab
+3. Copy the value of the `sp_dc` cookie
+
+
+
#### Apple Music
```java
AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
@@ -196,7 +205,9 @@ Snapshot builds are available in https://maven.lavalink.dev/snapshots with the s
For all supported urls and queries see [here](#supported-urls-and-queries)
-To get your Spotify clientId & clientSecret go [here](https://developer.spotify.com/dashboard/applications) & then copy them into your `application.yml` like the following.
+To get your Spotify clientId, clientSecret go [here](https://developer.spotify.com/dashboard/applications) & then copy them into your `application.yml` like the following.
+
+To get your Spotify spDc cookie go [here](#spotify)
To get your Apple Music api token go [here](#apple-music)
@@ -222,6 +233,7 @@ plugins:
spotify:
clientId: "your client id"
clientSecret: "your client secret"
+ # spDc: "your sp dc cookie" # the sp dc cookie used for accessing the spotify lyrics api
countryCode: "US" # the country code you want to use for filtering the artists top tracks. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
playlistLoadLimit: 6 # The number of pages at 100 tracks each
albumLoadLimit: 6 # The number of pages at 50 tracks each
@@ -247,6 +259,8 @@ plugins:
silence: 0 # the silence parameter is in milliseconds. Range is 0 to 10000. The default is 0.
speed: 1.0 # the speed parameter is a float between 0.5 and 10. The default is 1.0. (0.5 is half speed, 2.0 is double speed, etc.)
audioFormat: "mp3" # supported formats are: mp3, ogg_opus, ogg_vorbis, aac, wav, and flac. Default format is mp3
+ youtube:
+ countryCode: "US" # the country code you want to use for searching lyrics via ISRC. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
```
### Plugin Info
diff --git a/application.example.yml b/application.example.yml
index 43dc0a8c..2618488e 100644
--- a/application.example.yml
+++ b/application.example.yml
@@ -16,6 +16,7 @@ plugins:
spotify:
clientId: "your client id"
clientSecret: "your client secret"
+ spDc: "your sp dc cookie" # the sp dc cookie used for accessing the spotify lyrics api
countryCode: "US" # the country code you want to use for filtering the artists top tracks. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
playlistLoadLimit: 6 # The number of pages at 100 tracks each
albumLoadLimit: 6 # The number of pages at 50 tracks each
@@ -34,6 +35,8 @@ plugins:
silence: 0 # the silence parameter is in milliseconds. Range is 0 to 10000. The default is 0.
speed: 1.0 # the speed parameter is a float between 0.5 and 10. The default is 1.0. (0.5 is half speed, 2.0 is double speed, etc.)
audioFormat: "mp3" # supported formats are: mp3, ogg_opus, ogg_vorbis, aac, wav, and flac. Default format is mp3
+ youtube:
+ countryCode: "US" # the country code you want to use for searching lyrics via ISRC. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
server: # REST and WS server
port: 2333
diff --git a/main/build.gradle b/main/build.gradle
deleted file mode 100644
index 9734353e..00000000
--- a/main/build.gradle
+++ /dev/null
@@ -1,41 +0,0 @@
-plugins {
- id "java-library"
- id "org.jetbrains.kotlin.jvm"
- id "org.jetbrains.kotlin.plugin.serialization"
-}
-archivesBaseName = "lavasrc"
-
-sourceCompatibility = JavaVersion.VERSION_11
-targetCompatibility = JavaVersion.VERSION_11
-
-java {
- withJavadocJar()
- withSourcesJar()
-}
-
-dependencies {
- api "com.github.topi314.lavasearch:lavasearch:1.0.0"
- compileOnly "dev.arbjerg:lavaplayer:2.0.4"
- compileOnly "dev.lavalink.youtube:common:1.1.0"
- implementation "org.jsoup:jsoup:1.15.3"
- implementation "commons-io:commons-io:2.7"
- implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
- implementation "org.jetbrains.kotlin:kotlin-annotations-jvm:1.9.0"
- implementation "com.auth0:java-jwt:4.4.0"
- compileOnly "org.slf4j:slf4j-api:2.0.7"
-}
-
-publishing {
- publications {
- maven(MavenPublication) {
- pom {
- artifactId archivesBaseName
- from components.java
- }
- }
- }
-}
-
-kotlin {
- jvmToolchain(11)
-}
diff --git a/main/build.gradle.kts b/main/build.gradle.kts
new file mode 100644
index 00000000..33a82e4f
--- /dev/null
+++ b/main/build.gradle.kts
@@ -0,0 +1,53 @@
+plugins {
+ `java-library`
+ kotlin("jvm")
+ kotlin("plugin.serialization")
+}
+
+base {
+ archivesName = "lavasrc"
+}
+
+java {
+ withJavadocJar()
+ withSourcesJar()
+ sourceCompatibility = JavaVersion.VERSION_11
+}
+
+dependencies {
+ api("com.github.topi314.lavasearch:lavasearch:1.0.0")
+ api("com.github.topi314.lavalyrics:lavalyrics:1.0.0")
+ compileOnly("dev.arbjerg:lavaplayer:2.0.4")
+ compileOnly("com.github.lavalink-devs.youtube-source:common:1.0.5")
+ implementation("org.jsoup:jsoup:1.15.3")
+ implementation("commons-io:commons-io:2.7")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
+ implementation("org.jetbrains.kotlin:kotlin-annotations-jvm:1.9.0")
+ implementation("com.auth0:java-jwt:4.4.0")
+ compileOnly("org.slf4j:slf4j-api:2.0.7")
+
+ lyricsDependency("protocol")
+ lyricsDependency("client")
+}
+
+publishing {
+ publications {
+ create("maven") {
+ pom {
+ artifactId = base.archivesName.get()
+ from(components["java"])
+ }
+ }
+ }
+}
+
+kotlin {
+ jvmToolchain(11)
+}
+
+
+fun DependencyHandlerScope.lyricsDependency(module: String) {
+ implementation("dev.schlaubi.lyrics", "$module-jvm", "2.2.2") {
+ isTransitive = false
+ }
+}
\ No newline at end of file
diff --git a/main/src/main/java/com/github/topi314/lavasrc/LavaSrcTools.java b/main/src/main/java/com/github/topi314/lavasrc/LavaSrcTools.java
index 64ad5f9f..21b33207 100644
--- a/main/src/main/java/com/github/topi314/lavasrc/LavaSrcTools.java
+++ b/main/src/main/java/com/github/topi314/lavasrc/LavaSrcTools.java
@@ -30,6 +30,9 @@ public static JsonBrowser fetchResponseAsJson(HttpInterface httpInterface, HttpU
var data = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
log.error("Server responded with not found to '{}': {}", request.getURI(), data);
return null;
+ } else if (statusCode == HttpStatus.SC_NO_CONTENT) {
+ log.error("Server responded with not content to '{}'", request.getURI());
+ return null;
} else if (!HttpClientTools.isSuccessWithContent(statusCode)) {
var data = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
log.error("Server responded with an error to '{}': {}", request.getURI(), data);
diff --git a/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java b/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java
index 5b0b9e4b..01c05596 100644
--- a/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java
+++ b/main/src/main/java/com/github/topi314/lavasrc/deezer/DeezerAudioSourceManager.java
@@ -1,5 +1,8 @@
package com.github.topi314.lavasrc.deezer;
+import com.github.topi314.lavalyrics.AudioLyricsManager;
+import com.github.topi314.lavalyrics.lyrics.AudioLyrics;
+import com.github.topi314.lavalyrics.lyrics.BasicAudioLyrics;
import com.github.topi314.lavasearch.AudioSearchManager;
import com.github.topi314.lavasearch.result.AudioSearchResult;
import com.github.topi314.lavasearch.result.BasicAudioSearchResult;
@@ -15,6 +18,7 @@
import com.sedmelluq.discord.lavaplayer.track.*;
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.impl.client.HttpClientBuilder;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -25,6 +29,9 @@
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -32,8 +39,9 @@
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.regex.Pattern;
+import java.util.stream.Collectors;
-public class DeezerAudioSourceManager extends ExtendedAudioSourceManager implements HttpConfigurable, AudioSearchManager {
+public class DeezerAudioSourceManager extends ExtendedAudioSourceManager implements HttpConfigurable, AudioSearchManager, AudioLyricsManager {
public static final Pattern URL_PATTERN = Pattern.compile("(https?://)?(www\\.)?deezer\\.com/(?[a-zA-Z]{2}/)?(?track|album|playlist|artist)/(?[0-9]+)");
public static final String SEARCH_PREFIX = "dzsearch:";
@@ -49,6 +57,7 @@ public class DeezerAudioSourceManager extends ExtendedAudioSourceManager impleme
private final String masterDecryptionKey;
private final HttpInterfaceManager httpInterfaceManager;
+ private Tokens tokens;
public DeezerAudioSourceManager(String masterDecryptionKey) {
if (masterDecryptionKey == null || masterDecryptionKey.isEmpty()) {
@@ -58,6 +67,43 @@ public DeezerAudioSourceManager(String masterDecryptionKey) {
this.httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager();
}
+ private void refreshSession() throws IOException {
+ var getSessionID = new HttpPost(DeezerAudioSourceManager.PRIVATE_API_BASE + "?method=deezer.ping&input=3&api_version=1.0&api_token=");
+ var json = LavaSrcTools.fetchResponseAsJson(this.getHttpInterface(), getSessionID);
+
+ checkResponse(json, "Failed to get session ID: ");
+ var sessionID = json.get("results").get("SESSION").text();
+
+ 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.getHttpInterface(), getUserToken);
+
+ checkResponse(json, "Failed to get user token: ");
+ this.tokens = new Tokens(
+ json.get("results").get("checkForm").text(),
+ json.get("results").get("USER").get("OPTIONS").get("license_token").text(),
+ Instant.now().plus(3600, ChronoUnit.SECONDS)
+ );
+ }
+
+ public Tokens getTokens() throws IOException {
+ if (this.tokens == null || Instant.now().isAfter(this.tokens.expireAt)) {
+ this.refreshSession();
+ }
+ return this.tokens;
+ }
+
+ static 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);
+ }
+ }
+
@NotNull
@Override
public String getSourceName() {
@@ -78,6 +124,67 @@ public AudioTrack decodeTrack(AudioTrackInfo trackInfo, DataInput input) throws
);
}
+ @Override
+ @Nullable
+ public AudioLyrics loadLyrics(@NotNull AudioTrack audioTrack) {
+ var deezerTackId = "";
+ if (audioTrack instanceof DeezerAudioTrack) {
+ deezerTackId = audioTrack.getIdentifier();
+ }
+
+ if (deezerTackId.isEmpty()) {
+ AudioItem item = AudioReference.NO_TRACK;
+ try {
+ if (audioTrack.getInfo().isrc != null && !audioTrack.getInfo().isrc.isEmpty()) {
+ item = this.getTrackByISRC(audioTrack.getInfo().isrc, false);
+ }
+ if (item == AudioReference.NO_TRACK) {
+ item = this.getSearch(String.format("%s %s", audioTrack.getInfo().title, audioTrack.getInfo().author), false);
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ if (item == AudioReference.NO_TRACK) {
+ return null;
+ }
+ if (item instanceof AudioTrack) {
+ deezerTackId = ((AudioTrack) item).getIdentifier();
+ } else if (item instanceof AudioPlaylist) {
+ var playlist = (AudioPlaylist) item;
+ if (!playlist.getTracks().isEmpty()) {
+ deezerTackId = playlist.getTracks().get(0).getIdentifier();
+ }
+ }
+ }
+
+ try {
+ return this.getLyrics(deezerTackId);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public AudioLyrics getLyrics(String id) throws IOException {
+ var json = this.getJson(PRIVATE_API_BASE + "?method=song.getLyrics&api_version=1.0&api_token=" + this.getTokens().api + "&sng_id=" + id);
+ if (json == null || json.get("results").values().isEmpty()) {
+ return null;
+ }
+
+ var results = json.get("results");
+ var lyricsText = results.get("LYRICS_TEXT").text();
+ var lyrics = new ArrayList();
+ for (var line : results.get("LYRICS_SYNC_JSON").values()) {
+ lyrics.add(new BasicAudioLyrics.BasicLine(
+ Duration.ofMillis(line.get("milliseconds").asLong(0)),
+ Duration.ofMillis(line.get("duration").asLong(0)),
+ line.get("line").text()
+ ));
+ }
+
+ return new BasicAudioLyrics("deezer", "LyricFind", lyricsText, lyrics);
+ }
+
@Override
@Nullable
public AudioSearchResult loadSearch(@NotNull String query, @NotNull Set types) {
@@ -376,4 +483,16 @@ public HttpInterface getHttpInterface() {
return this.httpInterfaceManager.getInterface();
}
+ public static class Tokens {
+ public String api;
+ public String license;
+ public Instant expireAt;
+
+ public Tokens(String api, String license, Instant expireAt) {
+ this.api = api;
+ this.license = license;
+ this.expireAt = expireAt;
+ }
+ }
+
}
diff --git a/main/src/main/java/com/github/topi314/lavasrc/spotify/SpotifySourceManager.java b/main/src/main/java/com/github/topi314/lavasrc/spotify/SpotifySourceManager.java
index 581ee8e6..b68635c8 100644
--- a/main/src/main/java/com/github/topi314/lavasrc/spotify/SpotifySourceManager.java
+++ b/main/src/main/java/com/github/topi314/lavasrc/spotify/SpotifySourceManager.java
@@ -1,5 +1,8 @@
package com.github.topi314.lavasrc.spotify;
+import com.github.topi314.lavalyrics.AudioLyricsManager;
+import com.github.topi314.lavalyrics.lyrics.AudioLyrics;
+import com.github.topi314.lavalyrics.lyrics.BasicAudioLyrics;
import com.github.topi314.lavasearch.AudioSearchManager;
import com.github.topi314.lavasearch.result.AudioSearchResult;
import com.github.topi314.lavasearch.result.BasicAudioSearchResult;
@@ -30,6 +33,7 @@
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.function.Consumer;
@@ -37,7 +41,7 @@
import java.util.regex.Pattern;
import java.util.stream.Collectors;
-public class SpotifySourceManager extends MirroringAudioSourceManager implements HttpConfigurable, AudioSearchManager {
+public class SpotifySourceManager extends MirroringAudioSourceManager implements HttpConfigurable, AudioSearchManager, AudioLyricsManager {
public static final Pattern URL_PATTERN = Pattern.compile("(https?://)(www\\.)?open\\.spotify\\.com/((?[a-zA-Z-]+)/)?(user/(?[a-zA-Z0-9-_]+)/)?(?track|album|playlist|artist)/(?[a-zA-Z0-9-_]+)");
public static final String SEARCH_PREFIX = "spsearch:";
@@ -48,31 +52,40 @@ public class SpotifySourceManager extends MirroringAudioSourceManager implements
public static final int PLAYLIST_MAX_PAGE_ITEMS = 100;
public static final int ALBUM_MAX_PAGE_ITEMS = 50;
public static final String API_BASE = "https://api.spotify.com/v1/";
+ public static final String CLIENT_API_BASE = "https://spclient.wg.spotify.com/";
public static final Set SEARCH_TYPES = Set.of(AudioSearchResult.Type.ALBUM, AudioSearchResult.Type.ARTIST, AudioSearchResult.Type.PLAYLIST, AudioSearchResult.Type.TRACK);
private static final Logger log = LoggerFactory.getLogger(SpotifySourceManager.class);
private final HttpInterfaceManager httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager();
private final String clientId;
private final String clientSecret;
+ private final String spDc;
private final String countryCode;
private int playlistPageLimit = 6;
private int albumPageLimit = 6;
private String token;
private Instant tokenExpire;
+ private String spToken;
+ private Instant spTokenExpire;
+
public SpotifySourceManager(String[] providers, String clientId, String clientSecret, String countryCode, AudioPlayerManager audioPlayerManager) {
- this(clientId, clientSecret, countryCode, unused -> audioPlayerManager, new DefaultMirroringAudioTrackResolver(providers));
+ this(clientId, clientSecret, null, countryCode, unused -> audioPlayerManager, new DefaultMirroringAudioTrackResolver(providers));
}
public SpotifySourceManager(String[] providers, String clientId, String clientSecret, String countryCode, Function audioPlayerManager) {
- this(clientId, clientSecret, countryCode, audioPlayerManager, new DefaultMirroringAudioTrackResolver(providers));
+ this(clientId, clientSecret, null, countryCode, audioPlayerManager, new DefaultMirroringAudioTrackResolver(providers));
}
public SpotifySourceManager(String clientId, String clientSecret, String countryCode, AudioPlayerManager audioPlayerManager, MirroringAudioTrackResolver mirroringAudioTrackResolver) {
- this(clientId, clientSecret, countryCode, unused -> audioPlayerManager, mirroringAudioTrackResolver);
+ this(clientId, clientSecret, null, countryCode, unused -> audioPlayerManager, mirroringAudioTrackResolver);
}
public SpotifySourceManager(String clientId, String clientSecret, String countryCode, Function audioPlayerManager, MirroringAudioTrackResolver mirroringAudioTrackResolver) {
+ this(clientId, clientSecret, null, countryCode, audioPlayerManager, mirroringAudioTrackResolver);
+ }
+
+ public SpotifySourceManager(String clientId, String clientSecret, String spDc, String countryCode, Function audioPlayerManager, MirroringAudioTrackResolver mirroringAudioTrackResolver) {
super(audioPlayerManager, mirroringAudioTrackResolver);
if (clientId == null || clientId.isEmpty()) {
@@ -85,6 +98,8 @@ public SpotifySourceManager(String clientId, String clientSecret, String country
}
this.clientSecret = clientSecret;
+ this.spDc = spDc;
+
if (countryCode == null || countryCode.isEmpty()) {
countryCode = "US";
}
@@ -99,11 +114,78 @@ public void setAlbumPageLimit(int albumPageLimit) {
this.albumPageLimit = albumPageLimit;
}
+ @NotNull
@Override
public String getSourceName() {
return "spotify";
}
+ @Override
+ @Nullable
+ public AudioLyrics loadLyrics(@NotNull AudioTrack audioTrack) {
+ var spotifyTackId = "";
+ if (audioTrack instanceof SpotifyAudioTrack) {
+ spotifyTackId = audioTrack.getIdentifier();
+ }
+
+ if (spotifyTackId.isEmpty()) {
+ AudioItem item = AudioReference.NO_TRACK;
+ try {
+ if (audioTrack.getInfo().isrc != null && !audioTrack.getInfo().isrc.isEmpty()) {
+ item = this.getSearch("isrc:" + audioTrack.getInfo().isrc, false);
+ }
+ if (item == AudioReference.NO_TRACK) {
+ item = this.getSearch(String.format("%s %s", audioTrack.getInfo().title, audioTrack.getInfo().author), false);
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ if (item == AudioReference.NO_TRACK) {
+ return null;
+ }
+ if (item instanceof AudioTrack) {
+ spotifyTackId = ((AudioTrack) item).getIdentifier();
+ } else if (item instanceof AudioPlaylist) {
+ var playlist = (AudioPlaylist) item;
+ if (!playlist.getTracks().isEmpty()) {
+ spotifyTackId = playlist.getTracks().get(0).getIdentifier();
+ }
+ }
+ }
+
+ try {
+ return this.getLyrics(spotifyTackId);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public AudioLyrics getLyrics(String id) throws IOException {
+ if (this.spDc == null || this.spDc.isEmpty()) {
+ throw new IllegalArgumentException("Spotify spDc must be set");
+ }
+
+ var request = new HttpGet(CLIENT_API_BASE + "color-lyrics/v2/track/" + id + "?format=json&vocalRemoval=false");
+ request.addHeader("App-Platform", "WebPlayer");
+ request.addHeader("Authorization", "Bearer " + this.getSpToken());
+ var json = LavaSrcTools.fetchResponseAsJson(this.httpInterfaceManager.getInterface(), request);
+ if (json == null) {
+ return null;
+ }
+
+ var lyrics = new ArrayList();
+ for (var line : json.get("lyrics").get("lines").values()) {
+ lyrics.add(new BasicAudioLyrics.BasicLine(
+ Duration.ofMillis(line.get("startTimeMs").asLong(0)),
+ null,
+ line.get("words").text()
+ ));
+ }
+
+ return new BasicAudioLyrics("spotify", "MusixMatch", null, lyrics);
+ }
+
@Override
public AudioTrack decodeTrack(AudioTrackInfo trackInfo, DataInput input) throws IOException {
var extendedAudioTrackInfo = super.decodeTrack(input);
@@ -188,6 +270,22 @@ public AudioItem loadItem(String identifier, boolean preview) {
return null;
}
+ public void requestSpToken() throws IOException {
+ var request = new HttpGet("https://open.spotify.com/get_access_token?reason=transport&productType=web_player");
+ request.addHeader("Cookie", "sp_dc=" + this.spDc);
+
+ var json = LavaSrcTools.fetchResponseAsJson(this.httpInterfaceManager.getInterface(), request);
+ this.spToken = json.get("accessToken").text();
+ this.spTokenExpire = Instant.now().plusMillis(json.get("accessTokenExpirationTimestampMs").asLong(0));
+ }
+
+ public String getSpToken() throws IOException {
+ if (this.spToken == null || this.spTokenExpire == null || this.spTokenExpire.isBefore(Instant.now())) {
+ this.requestSpToken();
+ }
+ return this.spToken;
+ }
+
public void requestToken() throws IOException {
var request = new HttpPost("https://accounts.spotify.com/api/token");
request.addHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString((this.clientId + ":" + this.clientSecret).getBytes(StandardCharsets.UTF_8)));
diff --git a/main/src/main/java/com/github/topi314/lavasrc/youtube/YoutubeSearchManager.kt b/main/src/main/java/com/github/topi314/lavasrc/youtube/YoutubeSourceManager.kt
similarity index 88%
rename from main/src/main/java/com/github/topi314/lavasrc/youtube/YoutubeSearchManager.kt
rename to main/src/main/java/com/github/topi314/lavasrc/youtube/YoutubeSourceManager.kt
index f166b478..fc4f610b 100644
--- a/main/src/main/java/com/github/topi314/lavasrc/youtube/YoutubeSearchManager.kt
+++ b/main/src/main/java/com/github/topi314/lavasrc/youtube/YoutubeSourceManager.kt
@@ -1,5 +1,8 @@
package com.github.topi314.lavasrc.youtube
+import com.github.topi314.lavalyrics.AudioLyricsManager
+import com.github.topi314.lavalyrics.lyrics.AudioLyrics
+
import com.github.topi314.lavasearch.AudioSearchManager
import com.github.topi314.lavasearch.result.AudioSearchResult
import com.github.topi314.lavasearch.result.AudioText
@@ -7,11 +10,14 @@ import com.github.topi314.lavasearch.result.BasicAudioSearchResult
import com.github.topi314.lavasearch.result.BasicAudioText
import com.github.topi314.lavasrc.ExtendedAudioPlaylist
import com.github.topi314.lavasrc.youtube.innertube.MusicResponsiveListItemRenderer
+import com.github.topi314.lavasrc.youtube.innertube.requestLyrics
import com.github.topi314.lavasrc.youtube.innertube.requestMusicAutoComplete
+import com.github.topi314.lavasrc.youtube.innertube.takeFirstSearchResult
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo
+import dev.schlaubi.lyrics.LyricsNotFoundException
import dev.lavalink.youtube.YoutubeAudioSourceManager
import dev.lavalink.youtube.track.YoutubeAudioTrack
import org.apache.http.client.methods.HttpGet
@@ -31,8 +37,9 @@ private fun MusicResponsiveListItemRenderer.NavigationEndpoint.toUrl() = when {
}
class YoutubeSearchManager(
- private val playerManager: () -> AudioPlayerManager
-) : AudioSearchManager {
+ private val playerManager: () -> AudioPlayerManager,
+ private val region: String
+) : AudioSearchManager, AudioLyricsManager {
companion object {
const val SEARCH_PREFIX = "ytsearch:"
const val MUSIC_SEARCH_PREFIX = "ytmsearch:"
@@ -48,6 +55,20 @@ class YoutubeSearchManager(
private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager()
override fun getSourceName(): String = "youtube"
+ override fun loadLyrics(track: AudioTrack): AudioLyrics? = try {
+ httpInterfaceManager.`interface`.use {
+ val videoId = when {
+ track.sourceManager.sourceName == "youtube" -> track.info.identifier
+ track.info.isrc != null -> it.takeFirstSearchResult(track.info.isrc, region)
+ else -> it.takeFirstSearchResult("${track.info.title} - ${track.info.author}", region)
+ } ?: return@use null
+
+ it.requestLyrics(videoId)
+ }
+ } catch (e: LyricsNotFoundException) {
+ null
+ }
+
override fun loadSearch(query: String, types: Set): AudioSearchResult? {
val result = httpInterfaceManager.`interface`.use {
when {
diff --git a/main/src/main/java/com/github/topi314/lavasrc/youtube/innertube/client.kt b/main/src/main/java/com/github/topi314/lavasrc/youtube/innertube/client.kt
index 077c5781..56a4d405 100644
--- a/main/src/main/java/com/github/topi314/lavasrc/youtube/innertube/client.kt
+++ b/main/src/main/java/com/github/topi314/lavasrc/youtube/innertube/client.kt
@@ -1,21 +1,30 @@
package com.github.topi314.lavasrc.youtube.innertube
+import com.github.topi314.lavalyrics.lyrics.AudioLyrics
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface
+import dev.schlaubi.lyrics.LyricsNotFoundException
+import dev.schlaubi.lyrics.internal.model.*
+import dev.schlaubi.lyrics.internal.util.*
+import dev.schlaubi.lyrics.protocol.Lyrics
+import dev.schlaubi.lyrics.protocol.TextLyrics
+import dev.schlaubi.lyrics.protocol.TimedLyrics
+import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.*
import org.apache.http.HttpHeaders
import org.apache.http.client.methods.HttpPost
import org.apache.http.client.utils.URIBuilder
import org.apache.http.entity.ContentType
import org.apache.http.entity.StringEntity
import java.net.URI
+import java.time.Duration
import java.util.*
private val json = Json {
ignoreUnknownKeys = true
}
-fun HttpInterface.requestMusicAutoComplete(
+internal fun HttpInterface.requestMusicAutoComplete(
input: String,
locale: Locale? = null
): InnerTubeBox =
@@ -35,7 +44,61 @@ fun HttpInterface.requestMusicAutoComplete(
}
}
+private val emptyTrack = Lyrics.Track("", "", "", emptyList())
+@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+internal fun HttpInterface.requestLyrics(videoId: String): AudioLyrics {
+ val browse =
+ makeRequest<_, JsonObject>(youtubeMusic, "next", body = NextRequest(mobileYoutubeMusicContext, videoId))
+ val browseId = browse.browseEndpoint ?: throw LyricsNotFoundException()
+ val browseResult =
+ makeRequest<_, JsonObject>(youtubeMusic, "browse", body = BrowseRequest(mobileYoutubeMusicContext, browseId))
+ val lyricsData = browseResult.lyricsData
+ val data = if (lyricsData != null) {
+ val source = lyricsData.source
+ TimedLyrics(emptyTrack, source, lyricsData.lines)
+ } else {
+ val renderer = browseResult.musicDescriptionShelfRenderer ?: notFound()
+ val text = renderer.getRunningText("description")!!
+ val source = renderer.getRunningText("footer")!!
+ TextLyrics(emptyTrack, source, text)
+ }
+
+ return WrappedLyrics(data)
+}
+
+@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+internal fun HttpInterface.takeFirstSearchResult(query: String, region: String?): String? {
+ val result = makeRequest<_, JsonObject>(
+ youtubeMusic,
+ "search",
+ body = SearchRequest(mobileYoutubeMusicContext(region), query, onlyTracksSearchParam)
+ )
+ val section = result
+ .getJsonObject("contents")
+ ?.getJsonObject("tabbedSearchResultsRenderer")
+ ?.getJsonArray("tabs")
+ ?.getJsonObject(0)
+ ?.getJsonObject("tabRenderer")
+ ?.getJsonObject("content")
+ ?.getJsonObject("sectionListRenderer")
+ ?.getJsonArray("contents") ?: JsonArray(emptyList())
+
+ return section
+ .firstNotNullOfOrNull {
+ it.jsonObject.getJsonObject("musicShelfRenderer")
+ ?.getJsonArray("contents")
+ ?.firstNotNullOfOrNull { content ->
+ content.jsonObject.getJsonObject("musicTwoColumnItemRenderer")
+ ?.getJsonObject("navigationEndpoint")
+ ?.getJsonObject("watchEndpoint")
+ ?.getString("videoId")
+ }
+ }
+}
+
+
+@OptIn(ExperimentalSerializationApi::class)
private inline fun HttpInterface.makeRequest(
domain: URI,
vararg endpoint: String,
@@ -55,7 +118,29 @@ private inline fun HttpInterface.makeRequest(
}
val response = execute(post)
- val jsonText = response.entity.content.buffered().readAllBytes().decodeToString()
- return json.decodeFromString(jsonText)
+ return response.entity.content.buffered().use {
+ json.decodeFromStream(it)
+ }
+}
+
+private class WrappedLyrics(private val lyrics: Lyrics) : AudioLyrics {
+ override fun getSourceName(): String = "youtube"
+
+ override fun getProvider(): String = lyrics.source
+
+ override fun getText(): String = lyrics.text
+
+ override fun getLines(): MutableList? = (lyrics as? TimedLyrics)?.lines?.map {
+ Line(it)
+ }?.toMutableList()
+
+ private class Line(private val line: TimedLyrics.Line) : AudioLyrics.Line {
+ override fun getTimestamp(): Duration = Duration.ofMillis(line.range.first)
+
+ override fun getDuration(): Duration = Duration.ofMillis(line.range.last).minus(timestamp)
+
+ override fun getLine(): String = line.line
+
+ }
}
diff --git a/plugin/build.gradle b/plugin/build.gradle
index e0e114e9..9f37f417 100644
--- a/plugin/build.gradle
+++ b/plugin/build.gradle
@@ -10,14 +10,20 @@ lavalinkPlugin {
configurePublishing = false
}
-sourceCompatibility = JavaVersion.VERSION_17
-targetCompatibility = JavaVersion.VERSION_17
dependencies {
implementation project(":main")
compileOnly "dev.lavalink.youtube:common:1.1.0"
compileOnly "com.github.topi314.lavasearch:lavasearch:1.0.0"
implementation "com.github.topi314.lavasearch:lavasearch-plugin-api:1.0.0"
+ implementation "com.github.topi314.lavalyrics:lavalyrics-plugin-api:1.0.0"
+
+ // Copy lyrics.kt from main
+ project(":main").configurations.implementation.dependencies.forEach {
+ if (it.group == "dev.schlaubi.lyrics") {
+ add("implementation", it)
+ }
+ }
}
publishing {
diff --git a/plugin/src/main/java/com/github/topi314/lavasrc/plugin/LavaSrcPlugin.java b/plugin/src/main/java/com/github/topi314/lavasrc/plugin/LavaSrcPlugin.java
index 7d606f83..5db785de 100644
--- a/plugin/src/main/java/com/github/topi314/lavasrc/plugin/LavaSrcPlugin.java
+++ b/plugin/src/main/java/com/github/topi314/lavasrc/plugin/LavaSrcPlugin.java
@@ -1,10 +1,13 @@
package com.github.topi314.lavasrc.plugin;
+import com.github.topi314.lavalyrics.LyricsManager;
+import com.github.topi314.lavalyrics.api.LyricsManagerConfiguration;
import com.github.topi314.lavasearch.SearchManager;
import com.github.topi314.lavasearch.api.SearchManagerConfiguration;
import com.github.topi314.lavasrc.applemusic.AppleMusicSourceManager;
import com.github.topi314.lavasrc.deezer.DeezerAudioSourceManager;
import com.github.topi314.lavasrc.flowerytts.FloweryTTSSourceManager;
+import com.github.topi314.lavasrc.mirror.DefaultMirroringAudioTrackResolver;
import com.github.topi314.lavasrc.spotify.SpotifySourceManager;
import com.github.topi314.lavasrc.yandexmusic.YandexMusicSourceManager;
import com.github.topi314.lavasrc.youtube.YoutubeSearchManager;
@@ -16,7 +19,7 @@
import org.springframework.stereotype.Service;
@Service
-public class LavaSrcPlugin implements AudioPlayerManagerConfiguration, SearchManagerConfiguration {
+public class LavaSrcPlugin implements AudioPlayerManagerConfiguration, SearchManagerConfiguration, LyricsManagerConfiguration {
private static final Logger log = LoggerFactory.getLogger(LavaSrcPlugin.class);
@@ -28,11 +31,11 @@ public class LavaSrcPlugin implements AudioPlayerManagerConfiguration, SearchMan
private FloweryTTSSourceManager flowerytts;
private YoutubeSearchManager youtube;
- public LavaSrcPlugin(LavaSrcConfig pluginConfig, SourcesConfig sourcesConfig, SpotifyConfig spotifyConfig, AppleMusicConfig appleMusicConfig, DeezerConfig deezerConfig, YandexMusicConfig yandexMusicConfig, FloweryTTSConfig floweryTTSConfig) {
+ public LavaSrcPlugin(LavaSrcConfig pluginConfig, SourcesConfig sourcesConfig, SpotifyConfig spotifyConfig, AppleMusicConfig appleMusicConfig, DeezerConfig deezerConfig, YandexMusicConfig yandexMusicConfig, FloweryTTSConfig floweryTTSConfig, YouTubeConfig youTubeConfig) {
log.info("Loading LavaSrc plugin...");
if (sourcesConfig.isSpotify()) {
- this.spotify = new SpotifySourceManager(pluginConfig.getProviders(), spotifyConfig.getClientId(), spotifyConfig.getClientSecret(), spotifyConfig.getCountryCode(), unused -> manager);
+ this.spotify = new SpotifySourceManager(spotifyConfig.getClientId(), spotifyConfig.getClientSecret(), spotifyConfig.getSpDc(), spotifyConfig.getCountryCode(), unused -> manager, new DefaultMirroringAudioTrackResolver(pluginConfig.getProviders()));
if (spotifyConfig.getPlaylistLoadLimit() > 0) {
this.spotify.setPlaylistPageLimit(spotifyConfig.getPlaylistLoadLimit());
}
@@ -73,7 +76,7 @@ public LavaSrcPlugin(LavaSrcConfig pluginConfig, SourcesConfig sourcesConfig, Sp
if (sourcesConfig.isYoutube()) {
if (hasNewYoutubeSource()) {
log.info("Registering Youtube Source audio source manager...");
- this.youtube = new YoutubeSearchManager(() -> manager);
+ this.youtube = new YoutubeSearchManager(() -> manager, youTubeConfig.getCountryCode());
} else {
throw new IllegalStateException("Youtube LavaSearch requires either Lavaplayer Youtube or Youtube Source plugin to be enabled.");
}
@@ -138,4 +141,21 @@ public SearchManager configure(@NotNull SearchManager manager) {
return manager;
}
+ @NotNull
+ @Override
+ public LyricsManager configure(@NotNull LyricsManager manager) {
+ if (this.spotify != null) {
+ log.info("Registering Spotify lyrics manager...");
+ manager.registerLyricsManager(this.spotify);
+ }
+ if (this.deezer != null) {
+ log.info("Registering Deezer lyrics manager...");
+ manager.registerLyricsManager(this.deezer);
+ }
+ if (this.youtube != null) {
+ log.info("Registering YouTube lyrics manager...");
+ manager.registerLyricsManager(this.youtube);
+ }
+ return manager;
+ }
}
diff --git a/plugin/src/main/java/com/github/topi314/lavasrc/plugin/SpotifyConfig.java b/plugin/src/main/java/com/github/topi314/lavasrc/plugin/SpotifyConfig.java
index 568a6034..9730b790 100644
--- a/plugin/src/main/java/com/github/topi314/lavasrc/plugin/SpotifyConfig.java
+++ b/plugin/src/main/java/com/github/topi314/lavasrc/plugin/SpotifyConfig.java
@@ -9,6 +9,7 @@ public class SpotifyConfig {
private String clientId;
private String clientSecret;
+ private String spDc;
private String countryCode;
private int playlistLoadLimit;
private int albumLoadLimit;
@@ -29,6 +30,14 @@ public void setClientSecret(String clientSecret) {
this.clientSecret = clientSecret;
}
+ public String getSpDc() {
+ return this.spDc;
+ }
+
+ public void setSpDc(String spDc) {
+ this.spDc = spDc;
+ }
+
public String getCountryCode() {
return this.countryCode;
}
diff --git a/plugin/src/main/java/com/github/topi314/lavasrc/plugin/YouTubeConfig.java b/plugin/src/main/java/com/github/topi314/lavasrc/plugin/YouTubeConfig.java
new file mode 100644
index 00000000..5793b01f
--- /dev/null
+++ b/plugin/src/main/java/com/github/topi314/lavasrc/plugin/YouTubeConfig.java
@@ -0,0 +1,19 @@
+package com.github.topi314.lavasrc.plugin;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@ConfigurationProperties(prefix = "plugins.lavasrc.youtube")
+@Component
+public class YouTubeConfig {
+
+ private String countryCode;
+
+ public String getCountryCode() {
+ return countryCode;
+ }
+
+ public void setCountryCode(String countryCode) {
+ this.countryCode = countryCode;
+ }
+}