mirror of
https://github.com/Shabinder/SpotiFlyer.git
synced 2024-11-22 17:14:32 +01:00
JIO-Saavn Provider (WIP)
This commit is contained in:
parent
67361b1337
commit
99cce337c6
@ -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,
|
||||||
|
)
|
@ -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
|
||||||
|
)
|
@ -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,
|
||||||
|
)
|
@ -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,
|
||||||
|
)
|
@ -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
|
||||||
|
)
|
@ -20,4 +20,5 @@ enum class Source {
|
|||||||
Spotify,
|
Spotify,
|
||||||
YouTube,
|
YouTube,
|
||||||
Gaana,
|
Gaana,
|
||||||
|
JioSaavn
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
|
}
|
@ -21,11 +21,13 @@ import com.russhwolf.settings.Settings
|
|||||||
import com.shabinder.common.database.databaseModule
|
import com.shabinder.common.database.databaseModule
|
||||||
import com.shabinder.common.database.getLogger
|
import com.shabinder.common.database.getLogger
|
||||||
import com.shabinder.common.di.providers.GaanaProvider
|
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.SpotifyProvider
|
||||||
import com.shabinder.common.di.providers.YoutubeMp3
|
import com.shabinder.common.di.providers.YoutubeMp3
|
||||||
import com.shabinder.common.di.providers.YoutubeMusic
|
import com.shabinder.common.di.providers.YoutubeMusic
|
||||||
import com.shabinder.common.di.providers.YoutubeProvider
|
import com.shabinder.common.di.providers.YoutubeProvider
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.features.HttpTimeout
|
||||||
import io.ktor.client.features.json.JsonFeature
|
import io.ktor.client.features.json.JsonFeature
|
||||||
import io.ktor.client.features.json.serializer.KotlinxSerializer
|
import io.ktor.client.features.json.serializer.KotlinxSerializer
|
||||||
import io.ktor.client.features.logging.DEFAULT
|
import io.ktor.client.features.logging.DEFAULT
|
||||||
@ -57,9 +59,10 @@ fun commonModule(enableNetworkLogs: Boolean) = module {
|
|||||||
single { YoutubeMusic(get(), get()) }
|
single { YoutubeMusic(get(), get()) }
|
||||||
single { SpotifyProvider(get(), get(), get()) }
|
single { SpotifyProvider(get(), get(), get()) }
|
||||||
single { GaanaProvider(get(), get(), get()) }
|
single { GaanaProvider(get(), get(), get()) }
|
||||||
|
single { SaavnProvider(get(), get(), get()) }
|
||||||
single { YoutubeProvider(get(), get(), get()) }
|
single { YoutubeProvider(get(), get(), get()) }
|
||||||
single { YoutubeMp3(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
|
@ThreadLocal
|
||||||
@ -73,6 +76,7 @@ fun createHttpClient(enableNetworkLogs: Boolean = false) = HttpClient {
|
|||||||
install(JsonFeature) {
|
install(JsonFeature) {
|
||||||
serializer = KotlinxSerializer(globalJson)
|
serializer = KotlinxSerializer(globalJson)
|
||||||
}
|
}
|
||||||
|
install(HttpTimeout)
|
||||||
// WorkAround for Freezing
|
// WorkAround for Freezing
|
||||||
// Use httpClient.getData / httpClient.postData Extensions
|
// Use httpClient.getData / httpClient.postData Extensions
|
||||||
/*install(JsonFeature) {
|
/*install(JsonFeature) {
|
||||||
|
@ -18,6 +18,7 @@ package com.shabinder.common.di
|
|||||||
|
|
||||||
import com.shabinder.common.database.DownloadRecordDatabaseQueries
|
import com.shabinder.common.database.DownloadRecordDatabaseQueries
|
||||||
import com.shabinder.common.di.providers.GaanaProvider
|
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.SpotifyProvider
|
||||||
import com.shabinder.common.di.providers.YoutubeMp3
|
import com.shabinder.common.di.providers.YoutubeMp3
|
||||||
import com.shabinder.common.di.providers.YoutubeMusic
|
import com.shabinder.common.di.providers.YoutubeMusic
|
||||||
@ -30,6 +31,7 @@ class FetchPlatformQueryResult(
|
|||||||
val gaanaProvider: GaanaProvider,
|
val gaanaProvider: GaanaProvider,
|
||||||
val spotifyProvider: SpotifyProvider,
|
val spotifyProvider: SpotifyProvider,
|
||||||
val youtubeProvider: YoutubeProvider,
|
val youtubeProvider: YoutubeProvider,
|
||||||
|
val saavnProvider: SaavnProvider,
|
||||||
val youtubeMusic: YoutubeMusic,
|
val youtubeMusic: YoutubeMusic,
|
||||||
val youtubeMp3: YoutubeMp3,
|
val youtubeMp3: YoutubeMp3,
|
||||||
val dir: Dir
|
val dir: Dir
|
||||||
@ -47,6 +49,10 @@ class FetchPlatformQueryResult(
|
|||||||
link.contains("youtube.com", true) || link.contains("youtu.be", true) ->
|
link.contains("youtube.com", true) || link.contains("youtu.be", true) ->
|
||||||
youtubeProvider.query(link)
|
youtubeProvider.query(link)
|
||||||
|
|
||||||
|
// Jio Saavn
|
||||||
|
link.contains("saavn", true) ->
|
||||||
|
saavnProvider.query(link)
|
||||||
|
|
||||||
// GAANA
|
// GAANA
|
||||||
link.contains("gaana", true) ->
|
link.contains("gaana", true) ->
|
||||||
gaanaProvider.query(link)
|
gaanaProvider.query(link)
|
||||||
|
@ -76,7 +76,6 @@ class GaanaProvider(
|
|||||||
getGaanaSong(seokey = link).tracks.firstOrNull()?.also {
|
getGaanaSong(seokey = link).tracks.firstOrNull()?.also {
|
||||||
folderType = "Tracks"
|
folderType = "Tracks"
|
||||||
subFolder = ""
|
subFolder = ""
|
||||||
it.updateStatusIfPresent(folderType, subFolder)
|
|
||||||
trackList = listOf(it).toTrackDetailsList(folderType, subFolder)
|
trackList = listOf(it).toTrackDetailsList(folderType, subFolder)
|
||||||
title = it.track_title
|
title = it.track_title
|
||||||
coverUrl = it.artworkLink.replace("http:", "https:")
|
coverUrl = it.artworkLink.replace("http:", "https:")
|
||||||
@ -86,9 +85,6 @@ class GaanaProvider(
|
|||||||
getGaanaAlbum(seokey = link).also {
|
getGaanaAlbum(seokey = link).also {
|
||||||
folderType = "Albums"
|
folderType = "Albums"
|
||||||
subFolder = link
|
subFolder = link
|
||||||
it.tracks?.forEach { track ->
|
|
||||||
track.updateStatusIfPresent(folderType, subFolder)
|
|
||||||
}
|
|
||||||
trackList = it.tracks?.toTrackDetailsList(folderType, subFolder) ?: emptyList()
|
trackList = it.tracks?.toTrackDetailsList(folderType, subFolder) ?: emptyList()
|
||||||
title = link
|
title = link
|
||||||
coverUrl = it.custom_artworks.size_480p.replace("http:", "https:")
|
coverUrl = it.custom_artworks.size_480p.replace("http:", "https:")
|
||||||
@ -98,9 +94,6 @@ class GaanaProvider(
|
|||||||
getGaanaPlaylist(seokey = link).also {
|
getGaanaPlaylist(seokey = link).also {
|
||||||
folderType = "Playlists"
|
folderType = "Playlists"
|
||||||
subFolder = link
|
subFolder = link
|
||||||
it.tracks.forEach { track ->
|
|
||||||
track.updateStatusIfPresent(folderType, subFolder)
|
|
||||||
}
|
|
||||||
trackList = it.tracks.toTrackDetailsList(folderType, subFolder)
|
trackList = it.tracks.toTrackDetailsList(folderType, subFolder)
|
||||||
title = link
|
title = link
|
||||||
// coverUrl.value = "TODO"
|
// coverUrl.value = "TODO"
|
||||||
@ -117,9 +110,6 @@ class GaanaProvider(
|
|||||||
coverUrl = it.artworkLink?.replace("http:", "https:") ?: gaanaPlaceholderImageUrl
|
coverUrl = it.artworkLink?.replace("http:", "https:") ?: gaanaPlaceholderImageUrl
|
||||||
}
|
}
|
||||||
getGaanaArtistTracks(seokey = link).also {
|
getGaanaArtistTracks(seokey = link).also {
|
||||||
it.tracks?.forEach { track ->
|
|
||||||
track.updateStatusIfPresent(folderType, subFolder)
|
|
||||||
}
|
|
||||||
trackList = it.tracks?.toTrackDetailsList(folderType, subFolder) ?: emptyList()
|
trackList = it.tracks?.toTrackDetailsList(folderType, subFolder) ?: emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,14 +131,14 @@ class GaanaProvider(
|
|||||||
year = it.release_date,
|
year = it.release_date,
|
||||||
comment = "Genres:${it.genre?.map { genre -> genre?.name }?.reduceOrNull { acc, s -> acc + s }}",
|
comment = "Genres:${it.genre?.map { genre -> genre?.name }?.reduceOrNull { acc, s -> acc + s }}",
|
||||||
trackUrl = it.lyrics_url,
|
trackUrl = it.lyrics_url,
|
||||||
downloaded = it.downloaded ?: DownloadStatus.NotDownloaded,
|
downloaded = it.updateStatusIfPresent(type, subFolder),
|
||||||
source = Source.Gaana,
|
source = Source.Gaana,
|
||||||
albumArtURL = it.artworkLink.replace("http:", "https:"),
|
albumArtURL = it.artworkLink.replace("http:", "https:"),
|
||||||
outputFilePath = dir.finalOutputDir(it.track_title, type, subFolder, dir.defaultDir()/*,".m4a"*/)
|
outputFilePath = dir.finalOutputDir(it.track_title, type, subFolder, dir.defaultDir()/*,".m4a"*/)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String) {
|
private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus {
|
||||||
if (dir.isPresent(
|
return if (dir.isPresent(
|
||||||
dir.finalOutputDir(
|
dir.finalOutputDir(
|
||||||
track_title,
|
track_title,
|
||||||
folderType,
|
folderType,
|
||||||
@ -157,7 +147,9 @@ class GaanaProvider(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
) { // Download Already Present!!
|
) { // Download Already Present!!
|
||||||
downloaded = DownloadStatus.Downloaded
|
DownloadStatus.Downloaded.also {
|
||||||
|
downloaded = it
|
||||||
}
|
}
|
||||||
|
} else downloaded ?: DownloadStatus.NotDownloaded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -24,11 +24,13 @@ import com.shabinder.common.di.finalOutputDir
|
|||||||
import com.shabinder.common.di.globalJson
|
import com.shabinder.common.di.globalJson
|
||||||
import com.shabinder.common.di.spotify.SpotifyRequests
|
import com.shabinder.common.di.spotify.SpotifyRequests
|
||||||
import com.shabinder.common.di.spotify.authenticateSpotify
|
import com.shabinder.common.di.spotify.authenticateSpotify
|
||||||
|
import com.shabinder.common.models.DownloadStatus
|
||||||
import com.shabinder.common.models.NativeAtomicReference
|
import com.shabinder.common.models.NativeAtomicReference
|
||||||
import com.shabinder.common.models.PlatformQueryResult
|
import com.shabinder.common.models.PlatformQueryResult
|
||||||
import com.shabinder.common.models.TrackDetails
|
import com.shabinder.common.models.TrackDetails
|
||||||
import com.shabinder.common.models.spotify.Album
|
import com.shabinder.common.models.spotify.Album
|
||||||
import com.shabinder.common.models.spotify.Image
|
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.Source
|
||||||
import com.shabinder.common.models.spotify.Track
|
import com.shabinder.common.models.spotify.Track
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
@ -43,15 +45,6 @@ class SpotifyProvider(
|
|||||||
private val dir: Dir,
|
private val dir: Dir,
|
||||||
) : SpotifyRequests {
|
) : 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) {
|
override suspend fun authenticateSpotifyClient(override: Boolean) {
|
||||||
val token = if (override) authenticateSpotify() else tokenStore.getToken()
|
val token = if (override) authenticateSpotify() else tokenStore.getToken()
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
@ -133,7 +126,6 @@ class SpotifyProvider(
|
|||||||
getTrack(link).also {
|
getTrack(link).also {
|
||||||
folderType = "Tracks"
|
folderType = "Tracks"
|
||||||
subFolder = ""
|
subFolder = ""
|
||||||
it.updateStatusIfPresent(folderType, subFolder)
|
|
||||||
trackList = listOf(it).toTrackDetailsList(folderType, subFolder)
|
trackList = listOf(it).toTrackDetailsList(folderType, subFolder)
|
||||||
title = it.name.toString()
|
title = it.name.toString()
|
||||||
coverUrl = it.album?.images?.elementAtOrNull(0)?.url.toString()
|
coverUrl = it.album?.images?.elementAtOrNull(0)?.url.toString()
|
||||||
@ -145,7 +137,6 @@ class SpotifyProvider(
|
|||||||
folderType = "Albums"
|
folderType = "Albums"
|
||||||
subFolder = albumObject.name.toString()
|
subFolder = albumObject.name.toString()
|
||||||
albumObject.tracks?.items?.forEach {
|
albumObject.tracks?.items?.forEach {
|
||||||
it.updateStatusIfPresent(folderType, subFolder)
|
|
||||||
it.album = Album(
|
it.album = Album(
|
||||||
images = listOf(
|
images = listOf(
|
||||||
Image(
|
Image(
|
||||||
@ -170,25 +161,25 @@ class SpotifyProvider(
|
|||||||
val playlistObject = getPlaylist(link)
|
val playlistObject = getPlaylist(link)
|
||||||
folderType = "Playlists"
|
folderType = "Playlists"
|
||||||
subFolder = playlistObject.name.toString()
|
subFolder = playlistObject.name.toString()
|
||||||
val tempTrackList = mutableListOf<Track>()
|
val tempTrackList = mutableListOf<Track>().apply {
|
||||||
// log("Tracks Fetched", playlistObject.tracks?.items?.size.toString())
|
// Add Fetched Tracks
|
||||||
playlistObject.tracks?.items?.forEach {
|
playlistObject.tracks?.items?.mapNotNull(PlaylistTrack::track)?.let {
|
||||||
it.track?.let { it1 ->
|
addAll(it)
|
||||||
it1.updateStatusIfPresent(folderType, subFolder)
|
|
||||||
tempTrackList.add(it1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var moreTracksAvailable = !playlistObject.tracks?.next.isNullOrBlank()
|
|
||||||
|
|
||||||
while (moreTracksAvailable) {
|
|
||||||
// Check For More Tracks If available
|
// Check For More Tracks If available
|
||||||
|
var moreTracksAvailable = !playlistObject.tracks?.next.isNullOrBlank()
|
||||||
|
while (moreTracksAvailable) {
|
||||||
|
// Fetch Remaining Tracks
|
||||||
val moreTracks =
|
val moreTracks =
|
||||||
getPlaylistTracks(link, offset = tempTrackList.size)
|
getPlaylistTracks(link, offset = tempTrackList.size)
|
||||||
moreTracks.items?.forEach {
|
moreTracks.items?.mapNotNull(PlaylistTrack::track)?.let { remTracks ->
|
||||||
it.track?.let { it1 -> tempTrackList.add(it1) }
|
tempTrackList.addAll(remTracks)
|
||||||
}
|
}
|
||||||
moreTracksAvailable = !moreTracks.next.isNullOrBlank()
|
moreTracksAvailable = !moreTracks.next.isNullOrBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
// log("Total Tracks Fetched", tempTrackList.size.toString())
|
// log("Total Tracks Fetched", tempTrackList.size.toString())
|
||||||
trackList = tempTrackList.toTrackDetailsList(folderType, subFolder)
|
trackList = tempTrackList.toTrackDetailsList(folderType, subFolder)
|
||||||
title = playlistObject.name.toString()
|
title = playlistObject.name.toString()
|
||||||
@ -228,14 +219,14 @@ class SpotifyProvider(
|
|||||||
year = it.album?.release_date,
|
year = it.album?.release_date,
|
||||||
comment = "Genres:${it.album?.genres?.joinToString()}",
|
comment = "Genres:${it.album?.genres?.joinToString()}",
|
||||||
trackUrl = it.href,
|
trackUrl = it.href,
|
||||||
downloaded = it.downloaded,
|
downloaded = it.updateStatusIfPresent(type, subFolder),
|
||||||
source = Source.Spotify,
|
source = Source.Spotify,
|
||||||
albumArtURL = it.album?.images?.firstOrNull()?.url.toString(),
|
albumArtURL = it.album?.images?.firstOrNull()?.url.toString(),
|
||||||
outputFilePath = dir.finalOutputDir(it.name.toString(), type, subFolder, dir.defaultDir()/*,".m4a"*/)
|
outputFilePath = dir.finalOutputDir(it.name.toString(), type, subFolder, dir.defaultDir()/*,".m4a"*/)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
private fun Track.updateStatusIfPresent(folderType: String, subFolder: String) {
|
private fun Track.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus {
|
||||||
if (dir.isPresent(
|
return if (dir.isPresent(
|
||||||
dir.finalOutputDir(
|
dir.finalOutputDir(
|
||||||
name.toString(),
|
name.toString(),
|
||||||
folderType,
|
folderType,
|
||||||
@ -244,7 +235,9 @@ class SpotifyProvider(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
) { // Download Already Present!!
|
) { // Download Already Present!!
|
||||||
downloaded = com.shabinder.common.models.DownloadStatus.Downloaded
|
DownloadStatus.Downloaded.also {
|
||||||
|
downloaded = it
|
||||||
}
|
}
|
||||||
|
} else downloaded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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="
|
||||||
|
}
|
||||||
|
}
|
@ -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(""", "'")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("©", "©")
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
@ -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")
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
package com.shabinder.common.di.saavn
|
||||||
|
|
||||||
|
actual suspend fun decryptURL(url: String): String {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
package com.shabinder.common.di.saavn
|
||||||
|
|
||||||
|
actual suspend fun decryptURL(url: String): String {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
@ -4,29 +4,63 @@ import analytics_html_img.client
|
|||||||
import io.ktor.client.request.forms.FormDataContent
|
import io.ktor.client.request.forms.FormDataContent
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.http.Parameters
|
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.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
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 {
|
interface JioSaavnRequests {
|
||||||
|
|
||||||
fun searchForSong(
|
suspend fun searchForSong(
|
||||||
queryURL: String
|
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(
|
suspend fun getSong(
|
||||||
ID: String,
|
URL: String,
|
||||||
fetchLyrics: Boolean = false
|
fetchLyrics: Boolean = false
|
||||||
): JsonObject {
|
): SaavnSong {
|
||||||
return ((Json.parseToJsonElement(client.get(song_details_base_url + ID)) as JsonObject)[ID] as JsonObject)
|
val id = getSongID(URL)
|
||||||
|
val data = ((serializer.parseToJsonElement(client.get(song_details_base_url + id)) as JsonObject)[id] as JsonObject)
|
||||||
.formatData(fetchLyrics)
|
.formatData(fetchLyrics)
|
||||||
|
return serializer.decodeFromJsonElement(SaavnSong.serializer(), data)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSongID(
|
private suspend fun getSongID(
|
||||||
queryURL: String,
|
URL: String,
|
||||||
fetchLyrics: Boolean = false
|
): String {
|
||||||
): String? {
|
val res = client.get<String>(URL) {
|
||||||
val res = client.get<String>(queryURL) {
|
|
||||||
body = FormDataContent(
|
body = FormDataContent(
|
||||||
Parameters.build {
|
Parameters.build {
|
||||||
append("bitrate", "320")
|
append("bitrate", "320")
|
||||||
@ -36,7 +70,129 @@ interface JioSaavnRequests {
|
|||||||
return try {
|
return try {
|
||||||
res.split("\"song\":{\"type\":\"")[1].split("\",\"image\":")[0].split("\"id\":\"").last()
|
res.split("\"song\":{\"type\":\"")[1].split("\",\"image\":")[0].split("\"id\":\"").last()
|
||||||
} catch (e: IndexOutOfBoundsException) {
|
} 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", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import io.ktor.util.decodeBase64Bytes
|
|||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
@ -17,7 +18,7 @@ import javax.crypto.SecretKey
|
|||||||
import javax.crypto.SecretKeyFactory
|
import javax.crypto.SecretKeyFactory
|
||||||
import javax.crypto.spec.DESKeySpec
|
import javax.crypto.spec.DESKeySpec
|
||||||
|
|
||||||
internal fun JsonObject.formatData(
|
internal suspend fun JsonObject.formatData(
|
||||||
includeLyrics: Boolean = false
|
includeLyrics: Boolean = false
|
||||||
): JsonObject {
|
): JsonObject {
|
||||||
return buildJsonObject {
|
return buildJsonObject {
|
||||||
@ -26,10 +27,24 @@ internal fun JsonObject.formatData(
|
|||||||
this@formatData.forEach {
|
this@formatData.forEach {
|
||||||
if (it.value is JsonPrimitive && it.value.jsonPrimitive.isString) {
|
if (it.value is JsonPrimitive && it.value.jsonPrimitive.isString) {
|
||||||
put(it.key, it.value.jsonPrimitive.content.format())
|
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 {
|
} else {
|
||||||
put(it.key, it.value)
|
put(it.key, it.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var url = getString("media_preview_url")!!.replace("preview", "aac") // We Will catch NPE
|
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
|
// Add Media URL to JSON Object
|
||||||
put("media_url", url)
|
put("media_url", url)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
// e.printStackTrace()
|
||||||
// DECRYPT Encrypted Media URL
|
// DECRYPT Encrypted Media URL
|
||||||
getString("encrypted_media_url")?.let {
|
getString("encrypted_media_url")?.let {
|
||||||
put("media_url", decryptURL(it))
|
put("media_url", decryptURL(it))
|
||||||
@ -51,13 +66,29 @@ internal fun JsonObject.formatData(
|
|||||||
put("media_url", getString("media_url")?.replace("_320.mp4", "_160.mp4"))
|
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)
|
@OptIn(InternalAPI::class)
|
||||||
fun decryptURL(url: String): String {
|
suspend fun decryptURL(url: String): String {
|
||||||
val dks = DESKeySpec("38346591".toByteArray())
|
val dks = DESKeySpec("38346591".toByteArray())
|
||||||
val keyFactory = SecretKeyFactory.getInstance("DES")
|
val keyFactory = SecretKeyFactory.getInstance("DES")
|
||||||
val key: SecretKey = keyFactory.generateSecret(dks)
|
val key: SecretKey = keyFactory.generateSecret(dks)
|
||||||
@ -76,6 +107,7 @@ internal fun String.format(): String {
|
|||||||
.replace(""", "'")
|
.replace(""", "'")
|
||||||
.replace("&", "&")
|
.replace("&", "&")
|
||||||
.replace("'", "'")
|
.replace("'", "'")
|
||||||
|
.replace("©", "©")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun JsonObject.getString(key: String): String? = this[key]?.jsonPrimitive?.content
|
fun JsonObject.getString(key: String): String? = this[key]?.jsonPrimitive?.content
|
||||||
|
10
maintenance-tasks/src/main/java/jiosaavn/models/MoreInfo.kt
Normal file
10
maintenance-tasks/src/main/java/jiosaavn/models/MoreInfo.kt
Normal 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,
|
||||||
|
)
|
@ -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
|
||||||
|
)
|
@ -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,
|
||||||
|
)
|
@ -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,
|
||||||
|
)
|
41
maintenance-tasks/src/main/java/jiosaavn/models/SaavnSong.kt
Normal file
41
maintenance-tasks/src/main/java/jiosaavn/models/SaavnSong.kt
Normal 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
|
||||||
|
)
|
@ -1,14 +1,14 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import jiosaavn.JioSaavnRequests
|
import jiosaavn.JioSaavnRequests
|
||||||
|
import jiosaavn.models.SaavnPlaylist
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
// Test Class- at development Time
|
// Test Class- at development Time
|
||||||
fun main() = runBlocking {
|
fun main() = runBlocking {
|
||||||
val jioSaavnClient = object : JioSaavnRequests {}
|
val jioSaavnClient = object : JioSaavnRequests {}
|
||||||
val resp = jioSaavnClient.getSongID(
|
val resp: SaavnPlaylist? = jioSaavnClient.getPlaylist(
|
||||||
queryURL = "https://www.jiosaavn.com/song/nadiyon-paar-let-the-music-play-again-from-roohi/KAM0bj1AAn4"
|
URL = "https://www.jiosaavn.com/featured/hindi_chartbusters/u-75xwHI4ks_"
|
||||||
)
|
)
|
||||||
|
println(resp)
|
||||||
debug(jioSaavnClient.getSong(resp.toString()).toString())
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user