JIO-Saavn Provider (WIP)

This commit is contained in:
shabinder 2021-05-24 00:30:22 +05:30
parent 67361b1337
commit 99cce337c6
26 changed files with 943 additions and 63 deletions

View File

@ -0,0 +1,10 @@
package com.shabinder.common.models.saavn
import kotlinx.serialization.Serializable
@Serializable
data class MoreInfo(
val language: String,
val primary_artists: String,
val singers: String,
)

View File

@ -0,0 +1,17 @@
package com.shabinder.common.models.saavn
import kotlinx.serialization.Serializable
@Serializable
data class SaavnAlbum(
val albumid: String,
val image: String,
val name: String,
val perma_url: String,
val primary_artists: String,
val primary_artists_id: String,
val release_date: String,
val songs: List<SaavnSong>,
val title: String,
val year: String
)

View File

@ -0,0 +1,22 @@
package com.shabinder.common.models.saavn
import kotlinx.serialization.Serializable
@Serializable
data class SaavnPlaylist(
val fan_count: Int? = 0,
val firstname: String? = null,
val follower_count: Long? = null,
val image: String,
val images: List<String>? = null,
val last_updated: String,
val lastname: String? = null,
val list_count: String? = null,
val listid: String? = null,
val listname: String, // Title
val perma_url: String,
val songs: List<SaavnSong>,
val sub_types: List<String>? = null,
val type: String = "", // chart,etc
val uid: String? = null,
)

View File

@ -0,0 +1,17 @@
package com.shabinder.common.models.saavn
import kotlinx.serialization.Serializable
@Serializable
data class SaavnSearchResult(
val album: String? = "",
val description: String,
val id: String,
val image: String,
val title: String,
val type: String,
val url: String,
val ctr: Int? = 0,
val position: Int? = 0,
val more_info: MoreInfo? = null,
)

View File

@ -0,0 +1,46 @@
package com.shabinder.common.models.saavn
import com.shabinder.common.models.DownloadStatus
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable
data class SaavnSong @OptIn(ExperimentalSerializationApi::class) constructor(
@JsonNames("320kbps") val is320kbps: Boolean = false,
val album: String,
val album_url: String? = null,
val albumid: String? = null,
val artistMap: Map<String, String>,
val copyright_text: String? = null,
val duration: String,
val encrypted_media_path: String,
val encrypted_media_url: String,
val explicit_content: Int = 0,
val has_lyrics: Boolean = false,
val id: String,
val image: String,
val label: String? = null,
val label_url: String? = null,
val language: String,
val lyrics: String? = null,
val lyrics_snippet: String? = null,
val media_preview_url: String? = null,
val media_url: String? = null, // Downloadable M4A Link
val music: String,
val music_id: String,
val origin: String? = null,
val perma_url: String? = null,
val play_count: Int = 0,
val primary_artists: String,
val primary_artists_id: String,
val release_date: String, // Format - 2021-05-04
val singers: String,
val song: String, // title
val starring: String? = null,
val type: String = "",
val vcode: String? = null,
val vlink: String? = null,
val year: String,
var downloaded: DownloadStatus = DownloadStatus.NotDownloaded
)

View File

@ -20,4 +20,5 @@ enum class Source {
Spotify,
YouTube,
Gaana,
JioSaavn
}

View File

@ -0,0 +1,26 @@
package com.shabinder.common.di.saavn
import android.annotation.SuppressLint
import io.ktor.util.InternalAPI
import io.ktor.util.decodeBase64Bytes
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.DESKeySpec
@SuppressLint("GetInstance")
@OptIn(InternalAPI::class)
actual suspend fun decryptURL(url: String): String {
val dks = DESKeySpec("38346591".toByteArray())
val keyFactory = SecretKeyFactory.getInstance("DES")
val key: SecretKey = keyFactory.generateSecret(dks)
val cipher: Cipher = Cipher.getInstance("DES/ECB/PKCS5Padding").apply {
init(Cipher.DECRYPT_MODE, key, SecureRandom())
}
return cipher.doFinal(url.decodeBase64Bytes())
.decodeToString()
.replace("_96.mp4", "_320.mp4")
}

View File

@ -21,11 +21,13 @@ import com.russhwolf.settings.Settings
import com.shabinder.common.database.databaseModule
import com.shabinder.common.database.getLogger
import com.shabinder.common.di.providers.GaanaProvider
import com.shabinder.common.di.providers.SaavnProvider
import com.shabinder.common.di.providers.SpotifyProvider
import com.shabinder.common.di.providers.YoutubeMp3
import com.shabinder.common.di.providers.YoutubeMusic
import com.shabinder.common.di.providers.YoutubeProvider
import io.ktor.client.HttpClient
import io.ktor.client.features.HttpTimeout
import io.ktor.client.features.json.JsonFeature
import io.ktor.client.features.json.serializer.KotlinxSerializer
import io.ktor.client.features.logging.DEFAULT
@ -57,9 +59,10 @@ fun commonModule(enableNetworkLogs: Boolean) = module {
single { YoutubeMusic(get(), get()) }
single { SpotifyProvider(get(), get(), get()) }
single { GaanaProvider(get(), get(), get()) }
single { SaavnProvider(get(), get(), get()) }
single { YoutubeProvider(get(), get(), get()) }
single { YoutubeMp3(get(), get(), get()) }
single { FetchPlatformQueryResult(get(), get(), get(), get(), get(), get()) }
single { FetchPlatformQueryResult(get(), get(), get(), get(), get(), get(), get()) }
}
@ThreadLocal
@ -73,6 +76,7 @@ fun createHttpClient(enableNetworkLogs: Boolean = false) = HttpClient {
install(JsonFeature) {
serializer = KotlinxSerializer(globalJson)
}
install(HttpTimeout)
// WorkAround for Freezing
// Use httpClient.getData / httpClient.postData Extensions
/*install(JsonFeature) {

View File

@ -18,6 +18,7 @@ package com.shabinder.common.di
import com.shabinder.common.database.DownloadRecordDatabaseQueries
import com.shabinder.common.di.providers.GaanaProvider
import com.shabinder.common.di.providers.SaavnProvider
import com.shabinder.common.di.providers.SpotifyProvider
import com.shabinder.common.di.providers.YoutubeMp3
import com.shabinder.common.di.providers.YoutubeMusic
@ -30,6 +31,7 @@ class FetchPlatformQueryResult(
val gaanaProvider: GaanaProvider,
val spotifyProvider: SpotifyProvider,
val youtubeProvider: YoutubeProvider,
val saavnProvider: SaavnProvider,
val youtubeMusic: YoutubeMusic,
val youtubeMp3: YoutubeMp3,
val dir: Dir
@ -47,6 +49,10 @@ class FetchPlatformQueryResult(
link.contains("youtube.com", true) || link.contains("youtu.be", true) ->
youtubeProvider.query(link)
// Jio Saavn
link.contains("saavn", true) ->
saavnProvider.query(link)
// GAANA
link.contains("gaana", true) ->
gaanaProvider.query(link)

View File

@ -76,7 +76,6 @@ class GaanaProvider(
getGaanaSong(seokey = link).tracks.firstOrNull()?.also {
folderType = "Tracks"
subFolder = ""
it.updateStatusIfPresent(folderType, subFolder)
trackList = listOf(it).toTrackDetailsList(folderType, subFolder)
title = it.track_title
coverUrl = it.artworkLink.replace("http:", "https:")
@ -86,9 +85,6 @@ class GaanaProvider(
getGaanaAlbum(seokey = link).also {
folderType = "Albums"
subFolder = link
it.tracks?.forEach { track ->
track.updateStatusIfPresent(folderType, subFolder)
}
trackList = it.tracks?.toTrackDetailsList(folderType, subFolder) ?: emptyList()
title = link
coverUrl = it.custom_artworks.size_480p.replace("http:", "https:")
@ -98,9 +94,6 @@ class GaanaProvider(
getGaanaPlaylist(seokey = link).also {
folderType = "Playlists"
subFolder = link
it.tracks.forEach { track ->
track.updateStatusIfPresent(folderType, subFolder)
}
trackList = it.tracks.toTrackDetailsList(folderType, subFolder)
title = link
// coverUrl.value = "TODO"
@ -117,9 +110,6 @@ class GaanaProvider(
coverUrl = it.artworkLink?.replace("http:", "https:") ?: gaanaPlaceholderImageUrl
}
getGaanaArtistTracks(seokey = link).also {
it.tracks?.forEach { track ->
track.updateStatusIfPresent(folderType, subFolder)
}
trackList = it.tracks?.toTrackDetailsList(folderType, subFolder) ?: emptyList()
}
}
@ -141,14 +131,14 @@ class GaanaProvider(
year = it.release_date,
comment = "Genres:${it.genre?.map { genre -> genre?.name }?.reduceOrNull { acc, s -> acc + s }}",
trackUrl = it.lyrics_url,
downloaded = it.downloaded ?: DownloadStatus.NotDownloaded,
downloaded = it.updateStatusIfPresent(type, subFolder),
source = Source.Gaana,
albumArtURL = it.artworkLink.replace("http:", "https:"),
outputFilePath = dir.finalOutputDir(it.track_title, type, subFolder, dir.defaultDir()/*,".m4a"*/)
)
}
private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String) {
if (dir.isPresent(
private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus {
return if (dir.isPresent(
dir.finalOutputDir(
track_title,
folderType,
@ -157,7 +147,9 @@ class GaanaProvider(
)
)
) { // Download Already Present!!
downloaded = DownloadStatus.Downloaded
DownloadStatus.Downloaded.also {
downloaded = it
}
} else downloaded ?: DownloadStatus.NotDownloaded
}
}

View File

@ -0,0 +1,101 @@
package com.shabinder.common.di.providers
import co.touchlab.kermit.Kermit
import com.shabinder.common.di.Dir
import com.shabinder.common.di.finalOutputDir
import com.shabinder.common.di.saavn.JioSaavnRequests
import com.shabinder.common.di.utils.removeIllegalChars
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.saavn.SaavnSong
import com.shabinder.common.models.spotify.Source
import io.ktor.client.HttpClient
class SaavnProvider(
override val httpClient: HttpClient,
private val logger: Kermit,
private val dir: Dir,
) : JioSaavnRequests {
suspend fun query(fullLink: String): PlatformQueryResult {
val result = PlatformQueryResult(
folderType = "",
subFolder = "",
title = "",
coverUrl = "",
trackList = listOf(),
Source.JioSaavn
)
with(result) {
when (fullLink.substringAfter("saavn.com/").substringBefore("/")) {
"song" -> {
getSong(fullLink).let {
folderType = "Tracks"
subFolder = ""
trackList = listOf(it).toTrackDetails(folderType, subFolder)
title = it.song
coverUrl = it.image.replace("http:", "https:")
}
}
"album" -> {
getAlbum(fullLink)?.let {
folderType = "Albums"
subFolder = removeIllegalChars(it.title)
trackList = it.songs.toTrackDetails(folderType, subFolder)
title = it.title
coverUrl = it.image.replace("http:", "https:")
}
}
"featured" -> { // Playlist
getPlaylist(fullLink)?.let {
folderType = "Playlists"
subFolder = removeIllegalChars(it.listname)
trackList = it.songs.toTrackDetails(folderType, subFolder)
coverUrl = it.image.replace("http:", "https:")
title = it.listname
}
}
else -> {
// Handle Error
}
}
}
return result
}
private fun List<SaavnSong>.toTrackDetails(type: String, subFolder: String): List<TrackDetails> = this.map {
TrackDetails(
title = it.song,
artists = it.artistMap.keys.toMutableSet().apply { addAll(it.singers.split(",")) }.toList(),
durationSec = it.duration.toInt(),
albumName = it.album,
albumArtPath = dir.imageCacheDir() + (it.image.substringBeforeLast('/').substringAfterLast('/')) + ".jpeg",
year = it.year,
comment = it.copyright_text,
trackUrl = it.perma_url,
downloaded = it.updateStatusIfPresent(type, subFolder),
albumArtURL = it.image.replace("http:", "https:"),
lyrics = it.lyrics ?: it.lyrics_snippet,
videoID = it.media_url, // Downloadable Link
source = Source.JioSaavn,
outputFilePath = dir.finalOutputDir(it.song, type, subFolder, dir.defaultDir(), /*".m4a"*/)
)
}
private fun SaavnSong.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus {
return if (dir.isPresent(
dir.finalOutputDir(
song,
folderType,
subFolder,
dir.defaultDir()
)
)
) { // Download Already Present!!
DownloadStatus.Downloaded.also {
downloaded = it
}
} else downloaded
}
}

View File

@ -24,11 +24,13 @@ import com.shabinder.common.di.finalOutputDir
import com.shabinder.common.di.globalJson
import com.shabinder.common.di.spotify.SpotifyRequests
import com.shabinder.common.di.spotify.authenticateSpotify
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.NativeAtomicReference
import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.spotify.Album
import com.shabinder.common.models.spotify.Image
import com.shabinder.common.models.spotify.PlaylistTrack
import com.shabinder.common.models.spotify.Source
import com.shabinder.common.models.spotify.Track
import io.ktor.client.HttpClient
@ -43,15 +45,6 @@ class SpotifyProvider(
private val dir: Dir,
) : SpotifyRequests {
/* init {
logger.d { "Creating Spotify Provider" }
GlobalScope.launch(Dispatchers.Default) {
if (currentPlatform is AllPlatforms.Js) {
authenticateSpotifyClient(override = true)
} else authenticateSpotifyClient()
}
}*/
override suspend fun authenticateSpotifyClient(override: Boolean) {
val token = if (override) authenticateSpotify() else tokenStore.getToken()
if (token == null) {
@ -133,7 +126,6 @@ class SpotifyProvider(
getTrack(link).also {
folderType = "Tracks"
subFolder = ""
it.updateStatusIfPresent(folderType, subFolder)
trackList = listOf(it).toTrackDetailsList(folderType, subFolder)
title = it.name.toString()
coverUrl = it.album?.images?.elementAtOrNull(0)?.url.toString()
@ -145,7 +137,6 @@ class SpotifyProvider(
folderType = "Albums"
subFolder = albumObject.name.toString()
albumObject.tracks?.items?.forEach {
it.updateStatusIfPresent(folderType, subFolder)
it.album = Album(
images = listOf(
Image(
@ -170,25 +161,25 @@ class SpotifyProvider(
val playlistObject = getPlaylist(link)
folderType = "Playlists"
subFolder = playlistObject.name.toString()
val tempTrackList = mutableListOf<Track>()
// log("Tracks Fetched", playlistObject.tracks?.items?.size.toString())
playlistObject.tracks?.items?.forEach {
it.track?.let { it1 ->
it1.updateStatusIfPresent(folderType, subFolder)
tempTrackList.add(it1)
val tempTrackList = mutableListOf<Track>().apply {
// Add Fetched Tracks
playlistObject.tracks?.items?.mapNotNull(PlaylistTrack::track)?.let {
addAll(it)
}
}
var moreTracksAvailable = !playlistObject.tracks?.next.isNullOrBlank()
while (moreTracksAvailable) {
// Check For More Tracks If available
var moreTracksAvailable = !playlistObject.tracks?.next.isNullOrBlank()
while (moreTracksAvailable) {
// Fetch Remaining Tracks
val moreTracks =
getPlaylistTracks(link, offset = tempTrackList.size)
moreTracks.items?.forEach {
it.track?.let { it1 -> tempTrackList.add(it1) }
moreTracks.items?.mapNotNull(PlaylistTrack::track)?.let { remTracks ->
tempTrackList.addAll(remTracks)
}
moreTracksAvailable = !moreTracks.next.isNullOrBlank()
}
// log("Total Tracks Fetched", tempTrackList.size.toString())
trackList = tempTrackList.toTrackDetailsList(folderType, subFolder)
title = playlistObject.name.toString()
@ -228,14 +219,14 @@ class SpotifyProvider(
year = it.album?.release_date,
comment = "Genres:${it.album?.genres?.joinToString()}",
trackUrl = it.href,
downloaded = it.downloaded,
downloaded = it.updateStatusIfPresent(type, subFolder),
source = Source.Spotify,
albumArtURL = it.album?.images?.firstOrNull()?.url.toString(),
outputFilePath = dir.finalOutputDir(it.name.toString(), type, subFolder, dir.defaultDir()/*,".m4a"*/)
)
}
private fun Track.updateStatusIfPresent(folderType: String, subFolder: String) {
if (dir.isPresent(
private fun Track.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus {
return if (dir.isPresent(
dir.finalOutputDir(
name.toString(),
folderType,
@ -244,7 +235,9 @@ class SpotifyProvider(
)
)
) { // Download Already Present!!
downloaded = com.shabinder.common.models.DownloadStatus.Downloaded
DownloadStatus.Downloaded.also {
downloaded = it
}
} else downloaded
}
}

View File

@ -0,0 +1,209 @@
package com.shabinder.common.di.saavn
import com.shabinder.common.di.globalJson
import com.shabinder.common.models.saavn.SaavnAlbum
import com.shabinder.common.models.saavn.SaavnPlaylist
import com.shabinder.common.models.saavn.SaavnSearchResult
import com.shabinder.common.models.saavn.SaavnSong
import io.github.shabinder.utils.getBoolean
import io.github.shabinder.utils.getJsonArray
import io.github.shabinder.utils.getJsonObject
import io.github.shabinder.utils.getString
import io.ktor.client.HttpClient
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.get
import io.ktor.http.Parameters
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
interface JioSaavnRequests {
val httpClient: HttpClient
suspend fun searchForSong(
query: String,
includeLyrics: Boolean = true
): List<SaavnSearchResult> {
/*if (query.startsWith("http") && query.contains("saavn.com")) {
return listOf(getSong(query))
}*/
val searchURL = search_base_url + query
val results = mutableListOf<SaavnSearchResult>()
(globalJson.parseToJsonElement(httpClient.get(searchURL)) as JsonObject).getJsonObject("songs").getJsonArray("data")?.forEach {
(it as? JsonObject)?.formatData()?.let { jsonObject ->
results.add(globalJson.decodeFromJsonElement(SaavnSearchResult.serializer(), jsonObject))
}
}
return results
}
suspend fun getLyrics(ID: String): String? {
return (Json.parseToJsonElement(httpClient.get(lyrics_base_url + ID)) as JsonObject)
.getString("lyrics")
}
suspend fun getSong(
URL: String,
fetchLyrics: Boolean = false
): SaavnSong {
val id = getSongID(URL)
val data = ((globalJson.parseToJsonElement(httpClient.get(song_details_base_url + id)) as JsonObject)[id] as JsonObject)
.formatData(fetchLyrics)
return globalJson.decodeFromJsonElement(SaavnSong.serializer(), data)
}
private suspend fun getSongID(
URL: String,
): String {
val res = httpClient.get<String>(URL) {
body = FormDataContent(
Parameters.build {
append("bitrate", "320")
}
)
}
return try {
res.split("\"song\":{\"type\":\"")[1].split("\",\"image\":")[0].split("\"id\":\"").last()
} catch (e: IndexOutOfBoundsException) {
res.split("\"pid\":\"")[1].split("\",\"").first()
}
}
suspend fun getPlaylist(
URL: String,
includeLyrics: Boolean = false
): SaavnPlaylist? {
return try {
globalJson.decodeFromJsonElement(
SaavnPlaylist.serializer(),
(globalJson.parseToJsonElement(httpClient.get(playlist_details_base_url + getPlaylistID(URL))) as JsonObject)
.formatData(includeLyrics)
)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private suspend fun getPlaylistID(
URL: String
): String {
val res = httpClient.get<String>(URL)
return try {
res.split("\"type\":\"playlist\",\"id\":\"")[1].split('"')[0]
} catch (e: IndexOutOfBoundsException) {
res.split("\"page_id\",\"")[1].split("\",\"")[0]
}
}
suspend fun getAlbum(
URL: String,
includeLyrics: Boolean = false
): SaavnAlbum? {
return try {
globalJson.decodeFromJsonElement(
SaavnAlbum.serializer(),
(globalJson.parseToJsonElement(httpClient.get(album_details_base_url + getAlbumID(URL))) as JsonObject)
.formatData(includeLyrics)
)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private suspend fun getAlbumID(
URL: String
): String {
val res = httpClient.get<String>(URL)
return try {
res.split("\"album_id\":\"")[1].split('"')[0]
} catch (e: IndexOutOfBoundsException) {
res.split("\"page_id\",\"")[1].split("\",\"")[0]
}
}
private suspend fun JsonObject.formatData(
includeLyrics: Boolean = false
): JsonObject {
return buildJsonObject {
// Accommodate Incoming Json Object Data
// And `Format` everything while iterating
this@formatData.forEach {
if (it.value is JsonPrimitive && it.value.jsonPrimitive.isString) {
put(it.key, it.value.jsonPrimitive.content.format())
} else {
// Format Songs Nested Collection Too
if (it.key == "songs" && it.value is JsonArray) {
put(
it.key,
buildJsonArray {
getJsonArray("songs")?.forEach { song ->
(song as? JsonObject)?.formatData(includeLyrics)?.let { formattedSong ->
add(formattedSong)
}
}
}
)
} else {
put(it.key, it.value)
}
}
}
try {
var url = getString("media_preview_url")!!.replace("preview", "aac") // We Will catch NPE
url = if (getBoolean("320kbps") == true) {
url.replace("_96_p.mp4", "_320.mp4")
} else {
url.replace("_96_p.mp4", "_160.mp4")
}
// Add Media URL to JSON Object
put("media_url", url)
} catch (e: Exception) {
// e.printStackTrace()
// DECRYPT Encrypted Media URL
getString("encrypted_media_url")?.let {
put("media_url", decryptURL(it))
}
// Check if 320 Kbps is available or not
if (getBoolean("320kbps") != true && containsKey("media_url")) {
put("media_url", getString("media_url")?.replace("_320.mp4", "_160.mp4"))
}
}
// Increase Image Resolution
put(
"image",
getString("image")
?.replace("150x150", "500x500")
?.replace("50x50", "500x500")
)
// Fetch Lyrics if Requested
// Lyrics is HTML Based
if (includeLyrics) {
if (getBoolean("has_lyrics") == true) {
put("lyrics", getString("id")?.let { getLyrics(it) })
} else {
put("lyrics", "")
}
}
}
}
companion object {
// EndPoints
const val search_base_url = "https://www.jiosaavn.com/api.php?__call=autocomplete.get&_format=json&_marker=0&cc=in&includeMetaTags=1&query="
const val song_details_base_url = "https://www.jiosaavn.com/api.php?__call=song.getDetails&cc=in&_marker=0%3F_marker%3D0&_format=json&pids="
const val album_details_base_url = "https://www.jiosaavn.com/api.php?__call=content.getAlbumDetails&_format=json&cc=in&_marker=0%3F_marker%3D0&albumid="
const val playlist_details_base_url = "https://www.jiosaavn.com/api.php?__call=playlist.getDetails&_format=json&cc=in&_marker=0%3F_marker%3D0&listid="
const val lyrics_base_url = "https://www.jiosaavn.com/api.php?__call=lyrics.getLyrics&ctx=web6dot0&api_version=4&_format=json&_marker=0%3F_marker%3D0&lyrics_id="
}
}

View File

@ -0,0 +1,11 @@
package com.shabinder.common.di.saavn
expect suspend fun decryptURL(url: String): String
internal fun String.format(): String {
return this.unescape()
.replace("&quot;", "'")
.replace("&amp;", "&")
.replace("&#039;", "'")
.replace("&copy;", "©")
}

View File

@ -0,0 +1,94 @@
package com.shabinder.common.di.saavn
/*
* JSON UTILS
* */
fun String.escape(): String {
val output = StringBuilder()
for (element in this) {
val chx = element.toInt()
if (chx != 0) {
when (element) {
'\n' -> {
output.append("\\n")
}
'\t' -> {
output.append("\\t")
}
'\r' -> {
output.append("\\r")
}
'\\' -> {
output.append("\\\\")
}
'"' -> {
output.append("\\\"")
}
'\b' -> {
output.append("\\b")
}
/*chx >= 0x10000 -> {
assert(false) { "Java stores as u16, so it should never give us a character that's bigger than 2 bytes. It literally can't." }
}*/
/*chx > 127 -> {
output.append(String.format("\\u%04x", chx))
}*/
else -> {
output.append(element)
}
}
}
}
return output.toString()
}
fun String.unescape(): String {
val builder = StringBuilder()
var i = 0
while (i < this.length) {
val delimiter = this[i]
i++ // consume letter or backslash
if (delimiter == '\\' && i < this.length) {
// consume first after backslash
val ch = this[i]
i++
when (ch) {
'\\', '/', '"', '\'' -> {
builder.append(ch)
}
'n' -> builder.append('\n')
'r' -> builder.append('\r')
't' -> builder.append(
'\t'
)
'b' -> builder.append('\b')
'f' -> builder.append("\\f")
'u' -> {
val hex = StringBuilder()
// expect 4 digits
if (i + 4 > this.length) {
throw RuntimeException("Not enough unicode digits! ")
}
for (x in this.substring(i, i + 4).toCharArray()) {
// TODO in 1.5 Kotlin
/*if (!x.isLetterOrDigit()) {
throw RuntimeException("Bad character in unicode escape.")
}*/
hex.append(x.toLowerCase())
}
i += 4 // consume those four digits.
val code = hex.toString().toInt(16)
builder.append(code.toChar())
}
else -> {
throw RuntimeException("Illegal escape sequence: \\$ch")
}
}
} else { // it's not a backslash, or it's the last character.
builder.append(delimiter)
}
}
return builder.toString()
}

View File

@ -0,0 +1,26 @@
package com.shabinder.common.di.saavn
import android.annotation.SuppressLint
import io.ktor.util.InternalAPI
import io.ktor.util.decodeBase64Bytes
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.DESKeySpec
@SuppressLint("GetInstance")
@OptIn(InternalAPI::class)
actual suspend fun decryptURL(url: String): String {
val dks = DESKeySpec("38346591".toByteArray())
val keyFactory = SecretKeyFactory.getInstance("DES")
val key: SecretKey = keyFactory.generateSecret(dks)
val cipher: Cipher = Cipher.getInstance("DES/ECB/PKCS5Padding").apply {
init(Cipher.DECRYPT_MODE, key, SecureRandom())
}
return cipher.doFinal(url.decodeBase64Bytes())
.decodeToString()
.replace("_96.mp4", "_320.mp4")
}

View File

@ -0,0 +1,5 @@
package com.shabinder.common.di.saavn
actual suspend fun decryptURL(url: String): String {
TODO("Not yet implemented")
}

View File

@ -0,0 +1,5 @@
package com.shabinder.common.di.saavn
actual suspend fun decryptURL(url: String): String {
TODO("Not yet implemented")
}

View File

@ -4,29 +4,63 @@ import analytics_html_img.client
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.get
import io.ktor.http.Parameters
import jiosaavn.models.SaavnAlbum
import jiosaavn.models.SaavnPlaylist
import jiosaavn.models.SaavnSearchResult
import jiosaavn.models.SaavnSong
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
val serializer = Json {
ignoreUnknownKeys = true
isLenient = true
}
interface JioSaavnRequests {
fun searchForSong(
queryURL: String
) {
suspend fun searchForSong(
query: String,
includeLyrics: Boolean = true
): List<SaavnSearchResult> {
/*if (query.startsWith("http") && query.contains("saavn.com")) {
return listOf(getSong(query))
}*/
val searchURL = search_base_url + query
val results = mutableListOf<SaavnSearchResult>()
(serializer.parseToJsonElement(client.get(searchURL)) as JsonObject).getJsonObject("songs").getJsonArray("data")?.forEach {
(it as? JsonObject)?.formatData()?.let { jsonObject ->
results.add(serializer.decodeFromJsonElement(SaavnSearchResult.serializer(), jsonObject))
}
}
return results
}
suspend fun getLyrics(ID: String): String? {
return (Json.parseToJsonElement(client.get(lyrics_base_url + ID)) as JsonObject)
.getString("lyrics")
}
suspend fun getSong(
ID: String,
URL: String,
fetchLyrics: Boolean = false
): JsonObject {
return ((Json.parseToJsonElement(client.get(song_details_base_url + ID)) as JsonObject)[ID] as JsonObject)
): SaavnSong {
val id = getSongID(URL)
val data = ((serializer.parseToJsonElement(client.get(song_details_base_url + id)) as JsonObject)[id] as JsonObject)
.formatData(fetchLyrics)
return serializer.decodeFromJsonElement(SaavnSong.serializer(), data)
}
suspend fun getSongID(
queryURL: String,
fetchLyrics: Boolean = false
): String? {
val res = client.get<String>(queryURL) {
private suspend fun getSongID(
URL: String,
): String {
val res = client.get<String>(URL) {
body = FormDataContent(
Parameters.build {
append("bitrate", "320")
@ -36,7 +70,129 @@ interface JioSaavnRequests {
return try {
res.split("\"song\":{\"type\":\"")[1].split("\",\"image\":")[0].split("\"id\":\"").last()
} catch (e: IndexOutOfBoundsException) {
res.split("\"pid\":\"").getOrNull(1)?.split("\",\"")?.firstOrNull()
res.split("\"pid\":\"")[1].split("\",\"").first()
}
}
suspend fun getPlaylist(
URL: String,
includeLyrics: Boolean = false
): SaavnPlaylist? {
return try {
serializer.decodeFromJsonElement(
SaavnPlaylist.serializer(),
(serializer.parseToJsonElement(client.get(playlist_details_base_url + getPlaylistID(URL))) as JsonObject)
.formatData(includeLyrics)
)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private suspend fun getPlaylistID(
URL: String
): String {
val res = client.get<String>(URL)
return try {
res.split("\"type\":\"playlist\",\"id\":\"")[1].split('"')[0]
} catch (e: IndexOutOfBoundsException) {
res.split("\"page_id\",\"")[1].split("\",\"")[0]
}
}
suspend fun getAlbum(
URL: String,
includeLyrics: Boolean = false
): SaavnAlbum? {
return try {
serializer.decodeFromJsonElement(
SaavnAlbum.serializer(),
(serializer.parseToJsonElement(client.get(album_details_base_url + getAlbumID(URL))) as JsonObject)
.formatData(includeLyrics)
)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private suspend fun getAlbumID(
URL: String
): String {
val res = client.get<String>(URL)
return try {
res.split("\"album_id\":\"")[1].split('"')[0]
} catch (e: IndexOutOfBoundsException) {
res.split("\"page_id\",\"")[1].split("\",\"")[0]
}
}
private suspend fun JsonObject.formatData(
includeLyrics: Boolean = false
): JsonObject {
return buildJsonObject {
// Accommodate Incoming Json Object Data
// And `Format` everything while iterating
this@formatData.forEach {
if (it.value is JsonPrimitive && it.value.jsonPrimitive.isString) {
put(it.key, it.value.jsonPrimitive.content.format())
} else {
// Format Songs Nested Collection Too
if (it.key == "songs" && it.value is JsonArray) {
put(
it.key,
buildJsonArray {
getJsonArray("songs")?.forEach { song ->
(song as? JsonObject)?.formatData(includeLyrics)?.let { formattedSong ->
add(formattedSong)
}
}
}
)
} else {
put(it.key, it.value)
}
}
}
try {
var url = getString("media_preview_url")!!.replace("preview", "aac") // We Will catch NPE
url = if (getBoolean("320kbps") == true) {
url.replace("_96_p.mp4", "_320.mp4")
} else {
url.replace("_96_p.mp4", "_160.mp4")
}
// Add Media URL to JSON Object
put("media_url", url)
} catch (e: Exception) {
// e.printStackTrace()
// DECRYPT Encrypted Media URL
getString("encrypted_media_url")?.let {
put("media_url", decryptURL(it))
}
// Check if 320 Kbps is available or not
if (getBoolean("320kbps") != true && containsKey("media_url")) {
put("media_url", getString("media_url")?.replace("_320.mp4", "_160.mp4"))
}
}
// Increase Image Resolution
put(
"image",
getString("image")
?.replace("150x150", "500x500")
?.replace("50x50", "500x500")
)
// Fetch Lyrics if Requested
// Lyrics is HTML Based
if (includeLyrics) {
if (getBoolean("has_lyrics") == true) {
put("lyrics", getString("id")?.let { getLyrics(it) })
} else {
put("lyrics", "")
}
}
}
}

View File

@ -5,6 +5,7 @@ import io.ktor.util.decodeBase64Bytes
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
@ -17,7 +18,7 @@ import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.DESKeySpec
internal fun JsonObject.formatData(
internal suspend fun JsonObject.formatData(
includeLyrics: Boolean = false
): JsonObject {
return buildJsonObject {
@ -26,10 +27,24 @@ internal fun JsonObject.formatData(
this@formatData.forEach {
if (it.value is JsonPrimitive && it.value.jsonPrimitive.isString) {
put(it.key, it.value.jsonPrimitive.content.format())
} else {
// Format Songs Nested Collection Too
if (it.key == "songs" && it.value is JsonArray) {
put(
it.key,
buildJsonArray {
getJsonArray("songs")?.forEach { song ->
(song as? JsonObject)?.formatData(includeLyrics)?.let { formattedSong ->
add(formattedSong)
}
}
}
)
} else {
put(it.key, it.value)
}
}
}
try {
var url = getString("media_preview_url")!!.replace("preview", "aac") // We Will catch NPE
@ -41,7 +56,7 @@ internal fun JsonObject.formatData(
// Add Media URL to JSON Object
put("media_url", url)
} catch (e: Exception) {
e.printStackTrace()
// e.printStackTrace()
// DECRYPT Encrypted Media URL
getString("encrypted_media_url")?.let {
put("media_url", decryptURL(it))
@ -51,13 +66,29 @@ internal fun JsonObject.formatData(
put("media_url", getString("media_url")?.replace("_320.mp4", "_160.mp4"))
}
}
// Increase Image Resolution
put(
"image",
getString("image")
?.replace("150x150", "500x500")
?.replace("50x50", "500x500")
)
put("image", getString("image")?.replace("150x150", "500x500"))
// Fetch Lyrics if Requested
// Lyrics is HTML Based
if (includeLyrics) {
if (getBoolean("has_lyrics") == true) {
put("lyrics", getString("id")?.let { object : JioSaavnRequests {}.getLyrics(it) })
} else {
put("lyrics", "")
}
}
}
}
@Suppress("GetInstance")
@OptIn(InternalAPI::class)
fun decryptURL(url: String): String {
suspend fun decryptURL(url: String): String {
val dks = DESKeySpec("38346591".toByteArray())
val keyFactory = SecretKeyFactory.getInstance("DES")
val key: SecretKey = keyFactory.generateSecret(dks)
@ -76,6 +107,7 @@ internal fun String.format(): String {
.replace("&quot;", "'")
.replace("&amp;", "&")
.replace("&#039;", "'")
.replace("&copy;", "©")
}
fun JsonObject.getString(key: String): String? = this[key]?.jsonPrimitive?.content

View File

@ -0,0 +1,10 @@
package jiosaavn.models
import kotlinx.serialization.Serializable
@Serializable
data class MoreInfo(
val language: String,
val primary_artists: String,
val singers: String,
)

View File

@ -0,0 +1,17 @@
package jiosaavn.models
import kotlinx.serialization.Serializable
@Serializable
data class SaavnAlbum(
val albumid: String,
val image: String,
val name: String,
val perma_url: String,
val primary_artists: String,
val primary_artists_id: String,
val release_date: String,
val songs: List<SaavnSong>,
val title: String,
val year: String
)

View File

@ -0,0 +1,22 @@
package jiosaavn.models
import kotlinx.serialization.Serializable
@Serializable
data class SaavnPlaylist(
val fan_count: Int? = 0,
val firstname: String? = null,
val follower_count: Long? = null,
val image: String,
val images: List<String>? = null,
val last_updated: String,
val lastname: String? = null,
val list_count: String? = null,
val listid: String? = null,
val listname: String, // Title
val perma_url: String,
val songs: List<SaavnSong>,
val sub_types: List<String>? = null,
val type: String = "", // chart,etc
val uid: String? = null,
)

View File

@ -0,0 +1,17 @@
package jiosaavn.models
import kotlinx.serialization.Serializable
@Serializable
data class SaavnSearchResult(
val album: String? = "",
val description: String,
val id: String,
val image: String,
val title: String,
val type: String,
val url: String,
val ctr: Int? = 0,
val position: Int? = 0,
val more_info: MoreInfo? = null,
)

View File

@ -0,0 +1,41 @@
package jiosaavn.models
import kotlinx.serialization.Serializable
@Serializable
data class SaavnSong(
val `320kbps`: Boolean,
val album: String,
val album_url: String? = null,
val albumid: String? = null,
val artistMap: Map<String, String>,
val copyright_text: String? = null,
val duration: String,
val encrypted_media_path: String,
val encrypted_media_url: String,
val explicit_content: Int = 0,
val has_lyrics: Boolean = false,
val id: String,
val image: String,
val label: String? = null,
val label_url: String? = null,
val language: String,
val lyrics_snippet: String? = null,
val media_preview_url: String? = null,
val media_url: String? = null, // Downloadable M4A Link
val music: String,
val music_id: String,
val origin: String? = null,
val perma_url: String? = null,
val play_count: Int = 0,
val primary_artists: String,
val primary_artists_id: String,
val release_date: String, // Format - 2021-05-04
val singers: String,
val song: String, // title
val starring: String? = null,
val type: String = "",
val vcode: String? = null,
val vlink: String? = null,
val year: String
)

View File

@ -1,14 +1,14 @@
package utils
import jiosaavn.JioSaavnRequests
import jiosaavn.models.SaavnPlaylist
import kotlinx.coroutines.runBlocking
// Test Class- at development Time
fun main() = runBlocking {
val jioSaavnClient = object : JioSaavnRequests {}
val resp = jioSaavnClient.getSongID(
queryURL = "https://www.jiosaavn.com/song/nadiyon-paar-let-the-music-play-again-from-roohi/KAM0bj1AAn4"
val resp: SaavnPlaylist? = jioSaavnClient.getPlaylist(
URL = "https://www.jiosaavn.com/featured/hindi_chartbusters/u-75xwHI4ks_"
)
debug(jioSaavnClient.getSong(resp.toString()).toString())
println(resp)
}