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; + } +}