SoundCloud Impl

This commit is contained in:
Shabinder Singh 2021-10-04 02:41:57 +05:30
parent de4c84bdda
commit 7f279f2602
12 changed files with 218 additions and 225 deletions

View File

@ -8,6 +8,8 @@ import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import io.ktor.client.features.logging.*
import io.ktor.client.request.*
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.native.concurrent.SharedImmutable
@ -23,6 +25,18 @@ suspend fun isInternetAccessible(): Boolean {
}
}
// If Fails returns Input Url
suspend inline fun HttpClient.getFinalUrl(
url: String,
crossinline block: HttpRequestBuilder.() -> Unit = {}
): String {
return withContext(dispatcherIO) {
runCatching {
get<HttpResponse>(url,block).call.request.url.toString()
}.getOrNull() ?: url
}
}
fun createHttpClient(enableNetworkLogs: Boolean = false) = HttpClient {
// https://github.com/Kotlin/kotlinx.serialization/issues/1450
install(JsonFeature) {

View File

@ -44,7 +44,7 @@ data class TrackDetails(
var audioQuality: AudioQuality = AudioQuality.KBPS192,
var audioFormat: AudioFormat = AudioFormat.MP4,
var outputFilePath: String, // UriString in Android
var videoID: String? = null,
var videoID: String? = null, // will be used for purposes like Downloadable Link || VideoID etc. based on Provider
) : Parcelable {
val outputMp3Path get() = outputFilePath.substringBeforeLast(".") + ".mp3"
}

View File

@ -1,95 +0,0 @@
package com.shabinder.common.models.soundcloud
import com.shabinder.common.models.AudioFormat
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SoundCloudTrack(
@SerialName("artwork_url")
val artworkUrl: String = "",
//val caption: Any = Any(),
@SerialName("comment_count")
val commentCount: Int = 0,
val commentable: Boolean = false,
@SerialName("created_at")
val createdAt: String = "", //2015-05-21T16:36:39Z
val description: String = "",
@SerialName("display_date")
val displayDate: String = "",
@SerialName("download_count")
val downloadCount: Int = 0,
val downloadable: Boolean = false,
val duration: Int = 0, //290116
@SerialName("embeddable_by")
val embeddableBy: String = "",
@SerialName("full_duration")
val fullDuration: Int = 0,
val genre: String = "",
@SerialName("has_downloads_left")
val hasDownloadsLeft: Boolean = false,
val id: Int = 0,
val kind: String = "",
@SerialName("label_name")
val labelName: String = "",
@SerialName("last_modified")
val lastModified: String = "",
val license: String = "",
@SerialName("likes_count")
val likesCount: Int = 0,
val media: Media = Media(), // Important Data
@SerialName("monetization_model")
val monetizationModel: String = "",
val permalink: String = "",
@SerialName("permalink_url")
val permalinkUrl: String = "",
@SerialName("playback_count")
val playbackCount: Int = 0,
val policy: String = "",
val `public`: Boolean = false,
@SerialName("publisher_metadata")
val publisherMetadata: PublisherMetadata = PublisherMetadata(),
//@SerialName("purchase_title")
//val purchaseTitle: Any = Any(),
@SerialName("purchase_url")
val purchaseUrl: String = "", //"http://itunes.apple.com/us/album/sunrise-ep/id993328519"
@SerialName("release_date")
val releaseDate: String = "",
@SerialName("reposts_count")
val repostsCount: Int = 0,
//@SerialName("secret_token")
//val secretToken: Any = Any(),
val sharing: String = "",
val state: String = "",
@SerialName("station_permalink")
val stationPermalink: String = "",
@SerialName("station_urn")
val stationUrn: String = "",
val streamable: Boolean = false,
@SerialName("tag_list")
val tagList: String = "",
val title: String = "",
@SerialName("track_authorization")
val trackAuthorization: String = "",
@SerialName("track_format")
val trackFormat: String = "",
val uri: String = "",
val urn: String = "",
val user: User = User(),
@SerialName("user_id")
val userId: Int = 0,
//val visuals: Any = Any(),
@SerialName("waveform_url")
val waveformUrl: String = ""
) {
fun getDownloadableLink(): Pair<String, AudioFormat>? {
return (media.transcodings.firstOrNull {
it.quality == "hq" && (it.format.isProgressive || it.url.contains("progressive"))
} ?: media.transcodings.firstOrNull {
it.quality == "sq" && (it.format.isProgressive || it.url.contains("progressive"))
})?.let {
it.url to it.audioFormat
}
}
}

View File

@ -1,84 +0,0 @@
package com.shabinder.common.models.soundcloud
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Track(
@SerialName("artwork_url")
val artworkUrl: String = "",
val caption: String = "",
@SerialName("comment_count")
val commentCount: Int = 0,
val commentable: Boolean = false,
@SerialName("created_at")
val createdAt: String = "",
val description: String = "",
@SerialName("display_date")
val displayDate: String = "",
@SerialName("download_count")
val downloadCount: Int = 0,
val downloadable: Boolean = false,
val duration: Int = 0,
@SerialName("embeddable_by")
val embeddableBy: String = "",
@SerialName("full_duration")
val fullDuration: Int = 0,
val genre: String = "",
@SerialName("has_downloads_left")
val hasDownloadsLeft: Boolean = false,
val id: Int = 0,
val kind: String = "",
@SerialName("label_name")
val labelName: String = "",
@SerialName("last_modified")
val lastModified: String = "",
val license: String = "",
@SerialName("likes_count")
val likesCount: Int = 0,
val media: Media = Media(),
@SerialName("monetization_model")
val monetizationModel: String = "",
val permalink: String = "",
@SerialName("permalink_url")
val permalinkUrl: String = "",
@SerialName("playback_count")
val playbackCount: Int = 0,
val policy: String = "",
val `public`: Boolean = false,
@SerialName("publisher_metadata")
val publisherMetadata: PublisherMetadata = PublisherMetadata(),
@SerialName("purchase_title")
val purchaseTitle:String = "",
@SerialName("purchase_url")
val purchaseUrl: String = "",
@SerialName("release_date")
val releaseDate: String = "",
@SerialName("reposts_count")
val repostsCount: Int = 0,
@SerialName("secret_token")
val secretToken: String = "",
val sharing: String = "",
val state: String = "",
@SerialName("station_permalink")
val stationPermalink: String = "",
@SerialName("station_urn")
val stationUrn: String = "",
val streamable: Boolean = false,
@SerialName("tag_list")
val tagList: String = "",
val title: String = "",
@SerialName("track_authorization")
val trackAuthorization: String = "",
@SerialName("track_format")
val trackFormat: String = "",
val uri: String = "",
val urn: String = "",
val user: User = User(),
@SerialName("user_id")
val userId: Int = 0,
val visuals: String = "",
@SerialName("waveform_url")
val waveformUrl: String = ""
)

View File

@ -1,15 +1,12 @@
package com.shabinder.common.models.soundcloud.resolvemodel
import com.shabinder.common.models.AudioFormat
import com.shabinder.common.models.soundcloud.Media
import com.shabinder.common.models.soundcloud.PublisherMetadata
import com.shabinder.common.models.soundcloud.Track
import com.shabinder.common.models.soundcloud.User
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonClassDiscriminator
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
@Serializable
@JsonClassDiscriminator("kind")
@ -69,7 +66,7 @@ sealed class SoundCloudResolveResponseBase {
val title: String = "", //"Top 50: Hip-hop & Rap"
@SerialName("track_count")
val trackCount: Int = 0,
val tracks: List<Track> = emptyList(),
var tracks: List<SoundCloudResolveResponseTrack> = emptyList(),
val uri: String = "",
val user: User = User(),
@SerialName("user_id")
@ -155,5 +152,15 @@ sealed class SoundCloudResolveResponseBase {
val visuals: String = "",
@SerialName("waveform_url")
val waveformUrl: String = ""
) : SoundCloudResolveResponseBase()
) : SoundCloudResolveResponseBase() {
fun getDownloadableLink(): Pair<String, AudioFormat>? {
return (media.transcodings.firstOrNull {
it.quality == "hq" && (it.format.isProgressive || it.url.contains("progressive"))
} ?: media.transcodings.firstOrNull {
it.quality == "sq" && (it.format.isProgressive || it.url.contains("progressive"))
})?.let {
it.url to it.audioFormat
}
}
}
}

View File

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

View File

@ -20,7 +20,12 @@ import co.touchlab.kermit.Kermit
import com.shabinder.common.core_components.file_manager.FileManager
import com.shabinder.common.core_components.preference_manager.PreferenceManager
import com.shabinder.common.database.DownloadRecordDatabaseQueries
import com.shabinder.common.models.*
import com.shabinder.common.models.AudioFormat
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.dispatcherIO
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.event.coroutines.flatMapError
import com.shabinder.common.models.event.coroutines.onSuccess
@ -28,9 +33,9 @@ import com.shabinder.common.models.event.coroutines.success
import com.shabinder.common.models.spotify.Source
import com.shabinder.common.providers.gaana.GaanaProvider
import com.shabinder.common.providers.saavn.SaavnProvider
import com.shabinder.common.providers.sound_cloud.SoundCloudProvider
import com.shabinder.common.providers.spotify.SpotifyProvider
import com.shabinder.common.providers.youtube.YoutubeProvider
import com.shabinder.common.providers.youtube.get
import com.shabinder.common.providers.youtube_music.YoutubeMusic
import com.shabinder.common.providers.youtube_to_mp3.requests.YoutubeMp3
import com.shabinder.common.utils.appendPadded
@ -45,6 +50,7 @@ class FetchPlatformQueryResult(
private val spotifyProvider: SpotifyProvider,
private val youtubeProvider: YoutubeProvider,
private val saavnProvider: SaavnProvider,
private val soundCloudProvider: SoundCloudProvider,
private val youtubeMusic: YoutubeMusic,
private val youtubeMp3: YoutubeMp3,
val fileManager: FileManager,
@ -66,7 +72,7 @@ class FetchPlatformQueryResult(
link.contains("youtube.com", true) || link.contains("youtu.be", true) ->
youtubeProvider.query(link)
// Jio Saavn
// JioSaavn
link.contains("saavn", true) ->
saavnProvider.query(link)
@ -74,6 +80,10 @@ class FetchPlatformQueryResult(
link.contains("gaana", true) ->
gaanaProvider.query(link)
// SoundCloud
link.contains("soundcloud", true) ->
soundCloudProvider.query(link)
else -> {
SuspendableEvent.error(SpotiFlyerException.LinkInvalid(link))
}
@ -122,7 +132,7 @@ class FetchPlatformQueryResult(
ytMp3Link.component2()?.stackTraceToString()
?: "couldn't fetch link for ${track.videoID} ,trying manual extraction"
)
appendLine("Trying Local Extraction")
//appendLine("Trying Local Extraction")
null
} else {
audioFormat = AudioFormat.MP3
@ -130,6 +140,20 @@ class FetchPlatformQueryResult(
}
}
}
Source.SoundCloud -> {
audioFormat = track.audioFormat
soundCloudProvider.getDownloadURL(track).let {
if (it is SuspendableEvent.Failure || it.component1().isNullOrEmpty()) {
appendPadded(
"SoundCloud Provider Failed for ${track.title}:",
it.component2()?.stackTraceToString()
?: "couldn't fetch link for ${track.trackUrl}"
)
null
} else
it.component1()
}
}
else -> {
appendPadded(
"Invalid Arguments",

View File

@ -2,6 +2,7 @@ package com.shabinder.common.providers
import com.shabinder.common.providers.gaana.GaanaProvider
import com.shabinder.common.providers.saavn.SaavnProvider
import com.shabinder.common.providers.sound_cloud.SoundCloudProvider
import com.shabinder.common.providers.spotify.SpotifyProvider
import com.shabinder.common.providers.spotify.token_store.TokenStore
import com.shabinder.common.providers.youtube.YoutubeProvider
@ -16,7 +17,8 @@ fun providersModule(enableNetworkLogs: Boolean) = module {
single { GaanaProvider(get(), get(), get()) }
single { SaavnProvider(get(), get(), get()) }
single { YoutubeProvider(get(), get(), get()) }
single { SoundCloudProvider(get(), get(), get()) }
single { YoutubeMp3(get(), get()) }
single { YoutubeMusic(get(), get(), get(), get(), get()) }
single { FetchPlatformQueryResult(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
single { FetchPlatformQueryResult(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
}

View File

@ -2,12 +2,115 @@ package com.shabinder.common.providers.sound_cloud
import co.touchlab.kermit.Kermit
import com.shabinder.common.core_components.file_manager.FileManager
import com.shabinder.common.core_components.file_manager.finalOutputDir
import com.shabinder.common.models.AudioFormat
import com.shabinder.common.models.AudioQuality
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformQueryResult
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase.SoundCloudResolveResponsePlaylist
import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase.SoundCloudResolveResponseTrack
import com.shabinder.common.models.spotify.Source
import com.shabinder.common.providers.sound_cloud.requests.SoundCloudRequests
import com.shabinder.common.providers.sound_cloud.requests.doAuthenticatedRequest
import com.shabinder.common.utils.requireNotNull
import io.github.shabinder.utils.getString
import io.ktor.client.HttpClient
import kotlinx.serialization.json.JsonObject
class SoundCloudProvider(
private val logger: Kermit,
private val fileManager: FileManager,
) {
suspend fun query(fullURL: String) {
override val httpClient: HttpClient,
) : SoundCloudRequests {
suspend fun query(fullURL: String) = SuspendableEvent {
PlatformQueryResult(
folderType = "",
subFolder = "",
title = "",
coverUrl = "",
trackList = listOf(),
Source.SoundCloud
).apply {
when (val response = fetchResult(fullURL)) {
is SoundCloudResolveResponseTrack -> {
folderType = "Tracks"
subFolder = ""
trackList = listOf(response).toTrackDetailsList(folderType, subFolder)
coverUrl = response.artworkUrl
title = response.title
}
is SoundCloudResolveResponsePlaylist -> {
folderType = "Playlists"
subFolder = response.title
trackList = response.tracks.toTrackDetailsList(folderType, subFolder)
coverUrl = response.artworkUrl.ifBlank { response.calculatedArtworkUrl }
title = response.title
}
}
}
}
suspend fun getDownloadURL(trackDetails: TrackDetails) = SuspendableEvent {
doAuthenticatedRequest<JsonObject>(trackDetails.videoID.requireNotNull()).getString("url")
}
private fun List<SoundCloudResolveResponseTrack>.toTrackDetailsList(
type: String,
subFolder: String
): List<TrackDetails> =
map {
val downloadableInfo = it.getDownloadableLink()
TrackDetails(
title = it.title,
//trackNumber = it.track_number,
genre = listOf(it.genre),
artists = /*it.artists?.map { artist -> artist?.name.toString() } ?:*/ listOf(it.user.username.ifBlank { it.genre }),
albumArtists = /*it.album?.artists?.mapNotNull { artist -> artist?.name } ?:*/ emptyList(),
durationSec = (it.duration / 1000),
albumArtPath = fileManager.imageCacheDir() + (it.artworkUrl.formatArtworkUrl()).substringAfterLast(
'/'
) + ".jpeg",
albumName = "", //it.album?.name,
year = runCatching { it.displayDate.substring(0, 4) }.getOrNull(),
comment = it.caption,
trackUrl = it.permalinkUrl,
downloaded = it.updateStatusIfPresent(type, subFolder),
source = Source.SoundCloud,
albumArtURL = it.artworkUrl.formatArtworkUrl(),
outputFilePath = fileManager.finalOutputDir(
it.title,
type,
subFolder,
fileManager.defaultDir()/*,".m4a"*/
),
audioQuality = AudioQuality.KBPS128,
videoID = downloadableInfo?.first,
audioFormat = downloadableInfo?.second ?: AudioFormat.MP3
)
}
private fun SoundCloudResolveResponseTrack.updateStatusIfPresent(
folderType: String,
subFolder: String
): DownloadStatus {
return if (fileManager.isPresent(
fileManager.finalOutputDir(
title,
folderType,
subFolder,
fileManager.defaultDir()
)
)
) { // Download Already Present!!
DownloadStatus.Downloaded
} else
DownloadStatus.NotDownloaded
}
private fun String.formatArtworkUrl(): String {
return substringBeforeLast("-") + "-t500x500." + substringAfterLast(".")
}
}

View File

@ -1,31 +1,38 @@
package com.shabinder.common.providers.sound_cloud.requests
import com.shabinder.common.core_components.utils.getFinalUrl
import com.shabinder.common.models.SpotiFlyerException
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase
import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase.SoundCloudResolveResponsePlaylist
import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase.SoundCloudResolveResponseTrack
import io.github.shabinder.utils.getBoolean
import io.github.shabinder.utils.getString
import io.ktor.client.*
import io.ktor.client.features.*
import io.ktor.client.request.*
import io.ktor.client.HttpClient
import io.ktor.client.features.ClientRequestException
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.json.JsonObject
interface SoundCloudRequests {
val httpClient: HttpClient
suspend fun fetchResult(url: String): SoundCloudResolveResponseBase {
@Suppress("NAME_SHADOWING")
var url = url
suspend fun parseURL(url: String) {
getResponseObj(url).let { item ->
when (item) {
// Fetch Full URL if Input is Shortened URL from App
if (url.contains("soundcloud.app"))
url = httpClient.getFinalUrl(url)
return getResponseObj(url).run {
when (this) {
is SoundCloudResolveResponseTrack -> {
getTrack(item)
getTrack()
}
is SoundCloudResolveResponsePlaylist -> {
populatePlaylist()
}
else -> throw SpotiFlyerException.FeatureNotImplementedYet()
}
@ -33,8 +40,8 @@ interface SoundCloudRequests {
}
@Suppress("NAME_SHADOWING")
suspend fun getTrack(track: SoundCloudResolveResponseTrack): TrackDetails? {
val track = getTrackInfo(track)
suspend fun SoundCloudResolveResponseTrack.getTrack() = apply {
val track = populateTrackInfo()
if (track.policy == "BLOCK")
throw SpotiFlyerException.GeoLocationBlocked(extraInfo = "Use VPN to access ${track.title}")
@ -42,21 +49,38 @@ interface SoundCloudRequests {
if (!track.streamable)
throw SpotiFlyerException.LinkInvalid("\nSound Cloud Reports that ${track.title} is not streamable !\n")
return null
return track
}
@Suppress("NAME_SHADOWING")
suspend fun SoundCloudResolveResponsePlaylist.populatePlaylist(): SoundCloudResolveResponsePlaylist = apply {
supervisorScope {
try {
tracks = tracks.map {
async {
runCatching {
it.populateTrackInfo()
}.getOrNull() ?: it
}
}.awaitAll()
} catch (e: Throwable) {
e.printStackTrace()
}
}
}
suspend fun getTrackInfo(res: SoundCloudResolveResponseTrack): SoundCloudResolveResponseTrack {
if (res.media.transcodings.isNotEmpty())
return res
private suspend fun SoundCloudResolveResponseTrack.populateTrackInfo(): SoundCloudResolveResponseTrack {
if (media.transcodings.isNotEmpty())
return this
val infoURL = URLS.TRACK_INFO.buildURL(res.id.toString())
val infoURL = URLS.TRACK_INFO.buildURL(id.toString())
return httpClient.get(infoURL) {
parameter("client_id", CLIENT_ID)
}
}
suspend fun getResponseObj(url: String, clientID: String = CLIENT_ID): SoundCloudResolveResponseBase {
private suspend fun getResponseObj(url: String, clientID: String = CLIENT_ID): SoundCloudResolveResponseBase {
val itemURL = URLS.RESOLVE.buildURL(url)
val resp: SoundCloudResolveResponseBase = try {
httpClient.get(itemURL) {
@ -75,6 +99,7 @@ interface SoundCloudRequests {
return resp
}
@Suppress("unused")
companion object {
private enum class URLS(val buildURL: (arg: String) -> String) {
RESOLVE({ "https://api-v2.soundcloud.com/resolve?url=$it}" }),

View File

@ -79,7 +79,7 @@ class SpotifyProvider(
if (type == "episode" || type == "show") {
throw SpotiFlyerException.FeatureNotImplementedYet(
"Support for Spotify's ${type.uppercase()} isn't implemented yet"
"Support for Spotify's ${type.toUpperCase()} isn't implemented yet"
)
}

View File

@ -1,15 +1,15 @@
package com.shabinder.common.providers
import com.shabinder.common.core_components.utils.createHttpClient
import com.shabinder.common.core_components.utils.getFinalUrl
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.soundcloud.SoundCloudTrack
import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase
import com.shabinder.common.providers.utils.CommonUtils
import com.shabinder.common.providers.utils.SpotifyUtils
import com.shabinder.common.providers.utils.SpotifyUtils.toTrackDetailsList
import com.shabinder.common.utils.globalJson
import io.github.shabinder.runBlocking
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.serializer
import kotlin.test.Test
class TestSpotifyTrackMatching {
@ -27,12 +27,8 @@ class TestSpotifyTrackMatching {
@OptIn(InternalSerializationApi::class)
@Test
fun testRandomThing() = runBlocking {
globalJson.decodeFromString(SoundCloudResolveResponseBase.serializer(), """{"artwork_url":null,"trackCount":12,"kind":"playlist"}""")
.also {
println(it)
println(it is SoundCloudResolveResponseBase.SoundCloudResolveResponsePlaylist)
println(it is SoundCloudResolveResponseBase.SoundCloudResolveResponseTrack)
}
val res = createHttpClient().getFinalUrl("https://soundcloud.app.goo.gl/vrBzR")
println(res)
}