diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt index c54a8243..2bd75c2d 100644 --- a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/LyricsProviderService.kt @@ -1,7 +1,7 @@ package pl.lambada.songsync.data.remote.lyrics_providers import android.util.Log -import pl.lambada.songsync.data.remote.lyrics_providers.others.AppleAPI +import pl.lambada.songsync.data.remote.lyrics_providers.apple.AppleAPI import pl.lambada.songsync.data.remote.lyrics_providers.others.LRCLibAPI import pl.lambada.songsync.data.remote.lyrics_providers.others.MusixmatchAPI import pl.lambada.songsync.data.remote.lyrics_providers.others.NeteaseAPI @@ -34,7 +34,10 @@ class LyricsProviderService { // Netease Track ID and stuff private var neteaseID = 0L - + + // Apple API + private val appleAPI = AppleAPI() + // Apple Track ID private var appleID = 0L @@ -81,7 +84,7 @@ class LyricsProviderService { qqPayload = it?.qqPayload ?: "" } ?: throw NoTrackFoundException() - Providers.APPLE -> AppleAPI().getSongInfo(query, offset).also { + Providers.APPLE -> appleAPI.getSongInfo(query, offset).also { appleID = it?.appleID ?: 0 } ?: throw NoTrackFoundException() @@ -121,7 +124,7 @@ class LyricsProviderService { Providers.QQMUSIC -> QQMusicAPI().getSyncedLyrics(qqPayload, multiPersonWordByWord) - Providers.APPLE -> AppleAPI().getSyncedLyrics( + Providers.APPLE -> appleAPI.getSyncedLyrics( appleID, multiPersonWordByWord ) diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/apple/AppleAPI.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/apple/AppleAPI.kt new file mode 100644 index 00000000..72fc7a84 --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/apple/AppleAPI.kt @@ -0,0 +1,121 @@ +package pl.lambada.songsync.data.remote.lyrics_providers.apple + +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.statement.bodyAsText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import pl.lambada.songsync.data.remote.PaxMusicHelper +import pl.lambada.songsync.domain.model.SongInfo +import pl.lambada.songsync.domain.model.lyrics_providers.others.AppleMusicSearchResponse +import pl.lambada.songsync.util.EmptyQueryException +import pl.lambada.songsync.util.networking.Ktor.client +import pl.lambada.songsync.util.networking.Ktor.json +import java.net.URLEncoder + +class AppleAPI { + private val lyricsBaseURL = "https://lyrics.paxsenix.org/" + private val apiBaseURL = "https://amp-api.music.apple.com/v1/catalog/us" + private val tokenManager = AppleTokenManager() + + /** + * Searches for song information using the song name and artist name. + * @param query The SongInfo object with songName and artistName fields filled. + * @param offset The offset used for trying to find a better match or searching again. + * @return Search result as a SongInfo object. + */ + suspend fun getSongInfo(query: SongInfo, offset: Int = 0): SongInfo? { + val search = withContext(Dispatchers.IO) { + URLEncoder.encode( + "${query.songName} ${query.artistName}", + Charsets.UTF_8.toString() + ) + } + + if (search.isBlank()) + throw EmptyQueryException() + + return try { + val token = tokenManager.getToken() + + val response = client.get( + "$apiBaseURL/search?" + + "term=$search&" + + "types=songs&" + + "limit=25&" + + "l=en-US&" + + "platform=web&" + + "format[resources]=map&" + + "include[songs]=artists&" + + "extend=artistUrl" + ) { + header("Authorization", "Bearer $token") + header("Origin", "https://music.apple.com") + header("Referer", "https://music.apple.com/") + header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0") + header("Accept", "application/json") + header("Accept-Language", "en-US,en;q=0.5") + header("x-apple-renewal", "true") + } + + val responseBody = response.bodyAsText(Charsets.UTF_8) + + if (response.status.value !in 200..299) { + // Token might be expired, clear it and retry once + if (response.status.value == 401) { + tokenManager.clearToken() + } + return null + } + + val searchResponse = try { + json.decodeFromString(responseBody) + } catch (e: Exception) { + return null + } + + val songs = searchResponse.results.songs?.data ?: return null + + if (offset >= songs.size) + return null + + val songId = songs[offset].id + val songDetail = searchResponse.resources?.songs?.get(songId) ?: return null + val attributes = songDetail.attributes + + val artworkUrl = attributes.artwork.url + .replace("{w}", "100") + .replace("{h}", "100") + .replace("{f}", "png") + + SongInfo( + songName = attributes.name, + artistName = attributes.artistName, + songLink = attributes.url, + albumCoverLink = artworkUrl, + appleID = songId.toLongOrNull() ?: return null + ) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Gets synced lyrics using the song ID and returns them as a string formatted as an LRC file. + * @param id The ID of the song from search results. + * @param multiPersonWordByWord Flag to format lyrics for multiple persons word by word. + * @return The synced lyrics as a string. + */ + suspend fun getSyncedLyrics(id: Long, multiPersonWordByWord: Boolean): String? { + val response = client.get( + lyricsBaseURL + "apple-music/lyrics?id=$id" + ) + val responseBody = response.bodyAsText(Charsets.UTF_8) + + if (response.status.value !in 200..299 || responseBody.isEmpty()) + return null + + return PaxMusicHelper().formatWordByWordLyrics(responseBody, multiPersonWordByWord) + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/apple/AppleTokenManager.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/apple/AppleTokenManager.kt new file mode 100644 index 00000000..3e889805 --- /dev/null +++ b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/apple/AppleTokenManager.kt @@ -0,0 +1,46 @@ +package pl.lambada.songsync.data.remote.lyrics_providers.apple + +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import pl.lambada.songsync.util.networking.Ktor.client + +class AppleTokenManager { + private var cachedToken: String? = null + private val mutex = Mutex() + + suspend fun getToken(): String { + mutex.withLock { + cachedToken?.let { return it } + + try { + val mainPageResponse = client.get("https://beta.music.apple.com") + val mainPageBody = mainPageResponse.bodyAsText() + + val indexJsRegex = Regex("""/assets/index~[^/]+\.js""") + val indexJsMatch = indexJsRegex.find(mainPageBody) + ?: throw Exception("Could not find index-legacy script URL") + + val indexJsUri = indexJsMatch.value + + val indexJsResponse = client.get("https://beta.music.apple.com$indexJsUri") + val indexJsBody = indexJsResponse.bodyAsText() + + val tokenRegex = Regex("""eyJh([^"]*)""") + val tokenMatch = tokenRegex.find(indexJsBody) + ?: throw Exception("Could not find token") + + val token = tokenMatch.value + cachedToken = token + return token + } catch (e: Exception) { + throw Exception("Error fetching Apple Music token: ${e.message}", e) + } + } + } + + fun clearToken() { + cachedToken = null + } +} \ No newline at end of file diff --git a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/AppleAPI.kt b/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/AppleAPI.kt deleted file mode 100644 index 96f69499..00000000 --- a/app/src/main/java/pl/lambada/songsync/data/remote/lyrics_providers/others/AppleAPI.kt +++ /dev/null @@ -1,82 +0,0 @@ -package pl.lambada.songsync.data.remote.lyrics_providers.others - -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import pl.lambada.songsync.data.remote.PaxMusicHelper -import pl.lambada.songsync.domain.model.SongInfo -import pl.lambada.songsync.domain.model.lyrics_providers.others.AppleSearchResponse -import pl.lambada.songsync.util.EmptyQueryException -import pl.lambada.songsync.util.networking.Ktor.client -import pl.lambada.songsync.util.networking.Ktor.json -import java.net.URLEncoder - -class AppleAPI { - private val baseURL = "http://lyrics.paxsenix.dpdns.org/" - - /** - * Searches for song information using the song name and artist name. - * @param query The SongInfo object with songName and artistName fields filled. - * @param offset The offset used for trying to find a better match or searching again. - * @return Search result as a SongInfo object. - */ - suspend fun getSongInfo(query: SongInfo, offset: Int = 0): SongInfo? { - val search = withContext(Dispatchers.IO) { - URLEncoder.encode( - "${query.songName} ${query.artistName}", - Charsets.UTF_8.toString() - ) - } - - if (search.isBlank()) - throw EmptyQueryException() - - val response = client.get( - baseURL + "searchAppleMusic.php?q=$search" - ) - val responseBody = response.bodyAsText(Charsets.UTF_8) - - if (response.status.value !in 200..299) - return null - - val json = try { - json.decodeFromString>(responseBody) - } catch (e: Exception) { - return null - } - - val result = try { - json[offset] - } catch (e: IndexOutOfBoundsException) { - return null - } - - return SongInfo( - songName = result.songName, - artistName = result.artistName, - songLink = result.url, - albumCoverLink = result.artwork.replace("{w}", "100").replace("{h}", "100") - .replace("{f}", "png"), - appleID = result.id - ) - } - - /** - * Gets synced lyrics using the song ID and returns them as a string formatted as an LRC file. - * @param id The ID of the song from search results. - * @param multiPersonWordByWord Flag to format lyrics for multiple persons word by word. - * @return The synced lyrics as a string. - */ - suspend fun getSyncedLyrics(id: Long, multiPersonWordByWord: Boolean): String? { - val response = client.get( - baseURL + "getAppleMusicLyrics.php?id=$id" - ) - val responseBody = response.bodyAsText(Charsets.UTF_8) - - if (response.status.value !in 200..299 || responseBody.isEmpty()) - return null - - return PaxMusicHelper().formatWordByWordLyrics(responseBody, multiPersonWordByWord) - } -} diff --git a/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Apple.kt b/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Apple.kt index 2ac42cd9..af6c8cce 100644 --- a/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Apple.kt +++ b/app/src/main/java/pl/lambada/songsync/domain/model/lyrics_providers/others/Apple.kt @@ -1,18 +1,90 @@ package pl.lambada.songsync.domain.model.lyrics_providers.others +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class AppleSearchResponse( - val id: Long, - val songName: String, +data class AppleMusicSearchResponse( + val results: AppleMusicResults, + val resources: AppleMusicResources? = null +) + +@Serializable +data class AppleMusicResults( + val songs: AppleMusicSongsResult? = null +) + +@Serializable +data class AppleMusicSongsResult( + val data: List +) + +@Serializable +data class AppleMusicSongData( + val id: String, + val type: String, + val href: String +) + +@Serializable +data class AppleMusicResources( + val songs: Map? = null, + val artists: Map? = null +) + +@Serializable +data class AppleMusicSongDetail( + val id: String, + val type: String, + val attributes: AppleMusicSongAttributes, + val relationships: AppleMusicRelationships? = null +) + +@Serializable +data class AppleMusicSongAttributes( + val name: String, val artistName: String, val albumName: String, - val artwork: String, - val releaseDate: String?, - val duration: Int, - val isrc: String, + val artwork: AppleMusicArtwork, val url: String, - val contentRating: String?, - val albumId: String + val isrc: String? = null, + val releaseDate: String? = null, + val durationInMillis: Long? = null, + val hasTimeSyncedLyrics: Boolean? = null, + val contentRating: String? = null +) + +@Serializable +data class AppleMusicArtwork( + val url: String, + val width: Int? = null, + val height: Int? = null +) + +@Serializable +data class AppleMusicRelationships( + val artists: AppleMusicArtistsRelation? = null +) + +@Serializable +data class AppleMusicArtistsRelation( + val data: List +) + +@Serializable +data class AppleMusicArtistData( + val id: String, + val type: String +) + +@Serializable +data class AppleMusicArtistDetail( + val id: String, + val attributes: AppleMusicArtistAttributes +) + +@Serializable +data class AppleMusicArtistAttributes( + val name: String, + val url: String? = null ) \ No newline at end of file