diff --git a/common/data-models/build.gradle.kts b/common/data-models/build.gradle.kts index 65775562..5f8dd925 100644 --- a/common/data-models/build.gradle.kts +++ b/common/data-models/build.gradle.kts @@ -44,6 +44,7 @@ kotlin { implementation("co.touchlab:stately-concurrency:$statelyVersion") implementation("co.touchlab:stately-isolate:$statelyIsoVersion") implementation("co.touchlab:stately-iso-collections:$statelyIsoVersion") + implementation(Extras.youtubeDownloader) } } androidMain { diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/Ext.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/Ext.kt new file mode 100644 index 00000000..e8f24aea --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/Ext.kt @@ -0,0 +1,3 @@ +package com.shabinder.common + +fun T?.requireNotNull() : T = requireNotNull(this) \ No newline at end of file diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/AllPlatforms.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/AllPlatforms.kt deleted file mode 100644 index 14f894f1..00000000 --- a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/AllPlatforms.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * * Copyright (c) 2021 Shabinder Singh - * * This program is free software: you can redistribute it and/or modify - * * it under the terms of the GNU General Public License as published by - * * the Free Software Foundation, either version 3 of the License, or - * * (at your option) any later version. - * * - * * This program is distributed in the hope that it will be useful, - * * but WITHOUT ANY WARRANTY; without even the implied warranty of - * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * * GNU General Public License for more details. - * * - * * You should have received a copy of the GNU General Public License - * * along with this program. If not, see . - */ - -package com.shabinder.common.models - -sealed class AllPlatforms { - object Js : AllPlatforms() - object Jvm : AllPlatforms() - object Native : AllPlatforms() -} diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/CorsProxy.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/CorsProxy.kt index 75a9ee21..40e57a12 100644 --- a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/CorsProxy.kt +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/CorsProxy.kt @@ -16,6 +16,9 @@ package com.shabinder.common.models +import io.github.shabinder.TargetPlatforms +import io.github.shabinder.activePlatform + sealed class CorsProxy(open val url: String) { data class SelfHostedCorsProxy(override val url: String = "https://cors.spotiflyer.ml/cors/" /*"https://spotiflyer.azurewebsites.net/"*/) : CorsProxy(url) data class PublicProxyWithExtension(override val url: String = "https://cors.bridged.cc/") : CorsProxy(url) @@ -45,3 +48,5 @@ sealed class CorsProxy(open val url: String) { * Default Self Hosted, However ask user to use extension if possible. * */ var corsProxy: CorsProxy = CorsProxy.SelfHostedCorsProxy() + +val corsApi get() = if (activePlatform is TargetPlatforms.Js) corsProxy.url else "" \ No newline at end of file diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/SpotiFlyerException.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/SpotiFlyerException.kt new file mode 100644 index 00000000..472724e2 --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/SpotiFlyerException.kt @@ -0,0 +1,21 @@ +package com.shabinder.common.models + +sealed class SpotiFlyerException(override val message: String): Exception(message) { + + data class FeatureNotImplementedYet(override val message: String = "Feature not yet implemented."): SpotiFlyerException(message) + + data class NoMatchFound( + val trackName: String? = null, + override val message: String = "$trackName : NO Match Found!" + ): SpotiFlyerException(message) + + data class YoutubeLinkNotFound( + val videoID: String? = null, + override val message: String = "No Downloadable link found for videoID: $videoID" + ): SpotiFlyerException(message) + + data class LinkInvalid( + val link: String? = null, + override val message: String = "Entered Link is NOT Valid!\n ${link ?: ""}" + ): SpotiFlyerException(message) +} \ No newline at end of file diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Event.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Event.kt new file mode 100644 index 00000000..e3d6621d --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Event.kt @@ -0,0 +1,202 @@ +package com.shabinder.common.models.event + +inline fun Event<*, *>.getAs() = when (this) { + is Event.Success -> value as? X + is Event.Failure -> error as? X +} + +inline fun Event.success(f: (V) -> Unit) = fold(f, {}) + +inline fun Event<*, E>.failure(f: (E) -> Unit) = fold({}, f) + +infix fun Event.or(fallback: V) = when (this) { + is Event.Success -> this + else -> Event.Success(fallback) +} + +inline infix fun Event.getOrElse(fallback: (E) -> V): V { + return when (this) { + is Event.Success -> value + is Event.Failure -> fallback(error) + } +} + +fun Event.getOrNull(): V? { + return when (this) { + is Event.Success -> value + is Event.Failure -> null + } +} + +fun Event.getThrowableOrNull(): E? { + return when (this) { + is Event.Success -> null + is Event.Failure -> error + } +} + +inline fun Event.mapEither( + success: (V) -> U, + failure: (E) -> F +): Event { + return when (this) { + is Event.Success -> Event.success(success(value)) + is Event.Failure -> Event.error(failure(error)) + } +} + +inline fun Event.map(transform: (V) -> U): Event = try { + when (this) { + is Event.Success -> Event.Success(transform(value)) + is Event.Failure -> Event.Failure(error) + } +} catch (ex: Throwable) { + when (ex) { + is E -> Event.error(ex) + else -> throw ex + } +} + +inline fun Event.flatMap(transform: (V) -> Event): Event = + try { + when (this) { + is Event.Success -> transform(value) + is Event.Failure -> Event.Failure(error) + } + } catch (ex: Throwable) { + when (ex) { + is E -> Event.error(ex) + else -> throw ex + } + } + +inline fun Event.mapError(transform: (E) -> E2) = when (this) { + is Event.Success -> Event.Success(value) + is Event.Failure -> Event.Failure(transform(error)) +} + +inline fun Event.flatMapError(transform: (E) -> Event) = + when (this) { + is Event.Success -> Event.Success(value) + is Event.Failure -> transform(error) + } + +inline fun Event.onError(f: (E) -> Unit) = when (this) { + is Event.Success -> Event.Success(value) + is Event.Failure -> { + f(error) + this + } +} + +inline fun Event.onSuccess(f: (V) -> Unit): Event { + return when (this) { + is Event.Success -> { + f(value) + this + } + is Event.Failure -> this + } +} + +inline fun Event.any(predicate: (V) -> Boolean): Boolean = try { + when (this) { + is Event.Success -> predicate(value) + is Event.Failure -> false + } +} catch (ex: Throwable) { + false +} + +inline fun Event.fanout(other: () -> Event): Event, *> = + flatMap { outer -> other().map { outer to it } } + +inline fun List>.lift(): Event, E> = fold( + Event.success( + mutableListOf() + ) as Event, E> +) { acc, Event -> + acc.flatMap { combine -> + Event.map { combine.apply { add(it) } } + } +} + +inline fun Event.unwrap(failure: (E) -> Nothing): V = + apply { component2()?.let(failure) }.component1()!! + +inline fun Event.unwrapError(success: (V) -> Nothing): E = + apply { component1()?.let(success) }.component2()!! + + +sealed class Event { + + open operator fun component1(): V? = null + open operator fun component2(): E? = null + + inline fun fold(success: (V) -> X, failure: (E) -> X): X = when (this) { + is Success -> success(this.value) + is Failure -> failure(this.error) + } + + abstract fun get(): V + + class Success(val value: V) : Event() { + override fun component1(): V? = value + + override fun get(): V = value + + override fun toString() = "[Success: $value]" + + override fun hashCode(): Int = value.hashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is Success<*> && value == other.value + } + } + + class Failure(val error: E) : Event() { + override fun component2(): E? = error + + override fun get() = throw error + + fun getThrowable(): E = error + + override fun toString() = "[Failure: $error]" + + override fun hashCode(): Int = error.hashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is Failure<*> && error == other.error + } + } + + companion object { + // Factory methods + fun error(ex: E) = Failure(ex) + + fun success(v: V) = Success(v) + + inline fun of( + value: V?, + fail: (() -> Throwable) = { Throwable() } + ): Event = + value?.let { success(it) } ?: error(fail()) + + inline fun of(crossinline f: () -> V): Event = try { + success(f()) + } catch (ex: Throwable) { + when (ex) { + is E -> error(ex) + else -> throw ex + } + } + + inline operator fun invoke(crossinline f: () -> V): Event = try { + success(f()) + } catch (ex: Throwable) { + error(ex) + } + } +} \ No newline at end of file diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Factory.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Factory.kt new file mode 100644 index 00000000..9d47d6d7 --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Factory.kt @@ -0,0 +1,17 @@ +package com.shabinder.common.models.event + +inline fun runCatching(block: () -> V): Event { + return try { + Event.success(block()) + } catch (e: Throwable) { + Event.error(e) + } +} + +inline infix fun T.runCatching(block: T.() -> V): Event { + return try { + Event.success(block()) + } catch (e: Throwable) { + Event.error(e) + } +} \ No newline at end of file diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Validation.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Validation.kt new file mode 100644 index 00000000..d302c935 --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/Validation.kt @@ -0,0 +1,8 @@ +package com.shabinder.common.models.event + +class Validation(vararg resultSequence: Event<*, E>) { + + val failures: List = resultSequence.filterIsInstance>().map { it.getThrowable() } + + val hasFailure = failures.isNotEmpty() +} \ No newline at end of file diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/coroutines/SuspendableEvent.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/coroutines/SuspendableEvent.kt new file mode 100644 index 00000000..9e739ef8 --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/coroutines/SuspendableEvent.kt @@ -0,0 +1,159 @@ +package com.shabinder.common.models.event.coroutines + +inline fun SuspendableEvent<*, *>.getAs() = when (this) { + is SuspendableEvent.Success -> value as? X + is SuspendableEvent.Failure -> error as? X +} + +suspend inline fun SuspendableEvent.success(crossinline f: suspend (V) -> Unit) = fold(f, {}) + +suspend inline fun SuspendableEvent<*, E>.failure(crossinline f: suspend (E) -> Unit) = fold({}, f) + +infix fun SuspendableEvent.or(fallback: V) = when (this) { + is SuspendableEvent.Success -> this + else -> SuspendableEvent.Success(fallback) +} + +suspend inline infix fun SuspendableEvent.getOrElse(crossinline fallback:suspend (E) -> V): V { + return when (this) { + is SuspendableEvent.Success -> value + is SuspendableEvent.Failure -> fallback(error) + } +} + +fun SuspendableEvent.getOrNull(): V? { + return when (this) { + is SuspendableEvent.Success -> value + is SuspendableEvent.Failure -> null + } +} + +suspend inline fun SuspendableEvent.map( + crossinline transform: suspend (V) -> U +): SuspendableEvent = try { + when (this) { + is SuspendableEvent.Success -> SuspendableEvent.Success(transform(value)) + is SuspendableEvent.Failure -> SuspendableEvent.Failure(error) + } +} catch (ex: Throwable) { + SuspendableEvent.error(ex as E) +} + +suspend inline fun SuspendableEvent.flatMap( + crossinline transform: suspend (V) -> SuspendableEvent +): SuspendableEvent = try { + when (this) { + is SuspendableEvent.Success -> transform(value) + is SuspendableEvent.Failure -> SuspendableEvent.Failure(error) + } +} catch (ex: Throwable) { + SuspendableEvent.error(ex as E) +} + +suspend inline fun SuspendableEvent.mapError( + crossinline transform: suspend (E) -> E2 +) = when (this) { + is SuspendableEvent.Success -> SuspendableEvent.Success(value) + is SuspendableEvent.Failure -> SuspendableEvent.Failure(transform(error)) +} + +suspend inline fun SuspendableEvent.flatMapError( + crossinline transform: suspend (E) -> SuspendableEvent +) = when (this) { + is SuspendableEvent.Success -> SuspendableEvent.Success(value) + is SuspendableEvent.Failure -> transform(error) +} + +suspend inline fun SuspendableEvent.any( + crossinline predicate: suspend (V) -> Boolean +): Boolean = try { + when (this) { + is SuspendableEvent.Success -> predicate(value) + is SuspendableEvent.Failure -> false + } +} catch (ex: Throwable) { + false +} + +suspend inline fun SuspendableEvent.fanout( + crossinline other: suspend () -> SuspendableEvent +): SuspendableEvent, *> = + flatMap { outer -> other().map { outer to it } } + + +suspend fun List>.lift(): SuspendableEvent, E> = fold( + SuspendableEvent.Success, E>(mutableListOf()) as SuspendableEvent, E> +) { acc, result -> + acc.flatMap { combine -> + result.map { combine.apply { add(it) } } + } +} + +sealed class SuspendableEvent { + + abstract operator fun component1(): V? + abstract operator fun component2(): E? + + suspend inline fun fold(crossinline success: suspend (V) -> X, crossinline failure: suspend (E) -> X): X { + return when (this) { + is Success -> success(this.value) + is Failure -> failure(this.error) + } + } + + abstract val value: V + + class Success(override val value: V) : SuspendableEvent() { + override fun component1(): V? = value + override fun component2(): E? = null + + override fun toString() = "[Success: $value]" + + override fun hashCode(): Int = value.hashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is Success<*, *> && value == other.value + } + } + + class Failure(val error: E) : SuspendableEvent() { + override fun component1(): V? = null + override fun component2(): E? = error + + override val value: V = throw error + + fun getThrowable(): E = error + + override fun toString() = "[Failure: $error]" + + override fun hashCode(): Int = error.hashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is Failure<*, *> && error == other.error + } + } + + companion object { + // Factory methods + fun error(ex: E) = Failure(ex) + + inline fun of(value: V?,crossinline fail: (() -> Throwable) = { Throwable() }): SuspendableEvent { + return value?.let { Success(it) } ?: error(fail()) + } + + suspend inline fun of(crossinline f: suspend () -> V): SuspendableEvent = try { + Success(f()) + } catch (ex: Throwable) { + Failure(ex as E) + } + + suspend inline operator fun invoke(crossinline f: suspend () -> V): SuspendableEvent = try { + Success(f()) + } catch (ex: Throwable) { + Failure(ex) + } + } + +} \ No newline at end of file diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/coroutines/SuspendedValidation.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/coroutines/SuspendedValidation.kt new file mode 100644 index 00000000..ce29ec0d --- /dev/null +++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/event/coroutines/SuspendedValidation.kt @@ -0,0 +1,9 @@ +package com.shabinder.common.models.event.coroutines + +class SuspendedValidation(vararg resultSequence: SuspendableEvent<*, E>) { + + val failures: List = resultSequence.filterIsInstance>().map { it.getThrowable() } + + val hasFailure = failures.isNotEmpty() + +} \ No newline at end of file diff --git a/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/di/AndroidActual.kt b/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/di/AndroidActual.kt index 9514dbf4..3c4bf366 100644 --- a/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/di/AndroidActual.kt +++ b/common/dependency-injection/src/androidMain/kotlin/com/shabinder/common/di/AndroidActual.kt @@ -16,7 +16,6 @@ package com.shabinder.common.di -import com.shabinder.common.models.AllPlatforms import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.methods import kotlinx.coroutines.CoroutineDispatcher @@ -25,9 +24,6 @@ import kotlinx.coroutines.Dispatchers // IO-Dispatcher actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO -// Current Platform Info -actual val currentPlatform: AllPlatforms = AllPlatforms.Jvm - actual suspend fun downloadTracks( list: List, fetcher: FetchPlatformQueryResult, diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/DI.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/DI.kt index 2ed101db..fb531555 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/DI.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/DI.kt @@ -27,14 +27,11 @@ 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 -import io.ktor.client.features.logging.LogLevel -import io.ktor.client.features.logging.Logger -import io.ktor.client.features.logging.Logging +import io.ktor.client.* +import io.ktor.client.features.* +import io.ktor.client.features.json.* +import io.ktor.client.features.json.serializer.* +import io.ktor.client.features.logging.* import kotlinx.serialization.json.Json import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration @@ -62,7 +59,7 @@ fun commonModule(enableNetworkLogs: Boolean) = module { single { GaanaProvider(get(), get(), get()) } single { SaavnProvider(get(), get(), get(), get()) } single { YoutubeProvider(get(), get(), get()) } - single { YoutubeMp3(get(), get(), get()) } + single { YoutubeMp3(get(), get()) } single { YoutubeMusic(get(), get(), get(), get(), get()) } single { FetchPlatformQueryResult(get(), get(), get(), get(), get(), get(), get(), get()) } } diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/Expect.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/Expect.kt index 97fb7c46..1d1dfaf6 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/Expect.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/Expect.kt @@ -16,9 +16,8 @@ package com.shabinder.common.di -import com.shabinder.common.models.AllPlatforms import com.shabinder.common.models.TrackDetails -import io.ktor.client.request.head +import io.ktor.client.request.* import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -34,10 +33,6 @@ expect suspend fun downloadTracks( @SharedImmutable expect val dispatcherIO: CoroutineDispatcher -// Current Platform Info -@SharedImmutable -expect val currentPlatform: AllPlatforms - suspend fun isInternetAccessible(): Boolean { return withContext(dispatcherIO) { try { diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/gaana/GaanaRequests.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/gaana/GaanaRequests.kt index 99a232af..11f36a52 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/gaana/GaanaRequests.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/gaana/GaanaRequests.kt @@ -16,21 +16,15 @@ package com.shabinder.common.di.gaana -import com.shabinder.common.di.currentPlatform -import com.shabinder.common.models.AllPlatforms -import com.shabinder.common.models.corsProxy +import com.shabinder.common.models.corsApi import com.shabinder.common.models.gaana.GaanaAlbum import com.shabinder.common.models.gaana.GaanaArtistDetails import com.shabinder.common.models.gaana.GaanaArtistTracks import com.shabinder.common.models.gaana.GaanaPlaylist import com.shabinder.common.models.gaana.GaanaSong -import io.ktor.client.HttpClient -import io.ktor.client.request.get +import io.ktor.client.* +import io.ktor.client.request.* -val corsApi get() = if (currentPlatform is AllPlatforms.Js) { - corsProxy.url -} // "https://spotiflyer-cors.azurewebsites.net/" //"https://spotiflyer-cors.herokuapp.com/"//"https://cors.bridged.cc/" -else "" private const val TOKEN = "b2e6d7fbc136547a940516e9b77e5990" private val BASE_URL get() = "${corsApi}https://api.gaana.com" diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/GaanaProvider.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/GaanaProvider.kt index 476ca9b0..5fdfda90 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/GaanaProvider.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/GaanaProvider.kt @@ -22,10 +22,12 @@ import com.shabinder.common.di.finalOutputDir import com.shabinder.common.di.gaana.GaanaRequests import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.PlatformQueryResult +import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.TrackDetails +import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.gaana.GaanaTrack import com.shabinder.common.models.spotify.Source -import io.ktor.client.HttpClient +import io.ktor.client.* class GaanaProvider( override val httpClient: HttpClient, @@ -35,7 +37,7 @@ class GaanaProvider( private val gaanaPlaceholderImageUrl = "https://a10.gaanacdn.com/images/social/gaana_social.jpg" - suspend fun query(fullLink: String): PlatformQueryResult? { + suspend fun query(fullLink: String): SuspendableEvent = SuspendableEvent { // Link Schema: https://gaana.com/type/link val gaanaLink = fullLink.substringAfter("gaana.com/") @@ -44,17 +46,13 @@ class GaanaProvider( // Error if (type == "Error" || link == "Error") { - return null - } - return try { - gaanaSearch( - type, - link - ) - } catch (e: Exception) { - e.printStackTrace() - null + throw SpotiFlyerException.LinkInvalid() } + + gaanaSearch( + type, + link + ) } private suspend fun gaanaSearch( @@ -137,6 +135,7 @@ class GaanaProvider( outputFilePath = dir.finalOutputDir(it.track_title, type, subFolder, dir.defaultDir()/*,".m4a"*/) ) } + private fun GaanaTrack.updateStatusIfPresent(folderType: String, subFolder: String): DownloadStatus { return if (dir.isPresent( dir.finalOutputDir( diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SaavnProvider.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SaavnProvider.kt index 290744b7..ca78e490 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SaavnProvider.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SaavnProvider.kt @@ -8,10 +8,12 @@ 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.SpotiFlyerException import com.shabinder.common.models.TrackDetails +import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.saavn.SaavnSong import com.shabinder.common.models.spotify.Source -import io.ktor.client.HttpClient +import io.ktor.client.* class SaavnProvider( override val httpClient: HttpClient, @@ -20,16 +22,15 @@ class SaavnProvider( private val dir: Dir, ) : JioSaavnRequests { - suspend fun query(fullLink: String): PlatformQueryResult { - val result = PlatformQueryResult( + suspend fun query(fullLink: String): SuspendableEvent = SuspendableEvent { + PlatformQueryResult( folderType = "", subFolder = "", title = "", coverUrl = "", trackList = listOf(), Source.JioSaavn - ) - with(result) { + ).apply { when (fullLink.substringAfter("saavn.com/").substringBefore("/")) { "song" -> { getSong(fullLink).let { @@ -59,12 +60,10 @@ class SaavnProvider( } } else -> { - // Handle Error + throw SpotiFlyerException.LinkInvalid(fullLink) } } } - - return result } private fun List.toTrackDetails(type: String, subFolder: String): List = this.map { diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SpotifyProvider.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SpotifyProvider.kt index 44b67370..ac85927c 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SpotifyProvider.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/SpotifyProvider.kt @@ -27,17 +27,19 @@ 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.SpotiFlyerException import com.shabinder.common.models.TrackDetails +import com.shabinder.common.models.event.coroutines.SuspendableEvent 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 -import io.ktor.client.features.defaultRequest -import io.ktor.client.features.json.JsonFeature -import io.ktor.client.features.json.serializer.KotlinxSerializer -import io.ktor.client.request.header +import io.ktor.client.* +import io.ktor.client.features.* +import io.ktor.client.features.json.* +import io.ktor.client.features.json.serializer.* +import io.ktor.client.request.* class SpotifyProvider( private val tokenStore: TokenStore, @@ -64,7 +66,7 @@ class SpotifyProvider( override val httpClientRef = NativeAtomicReference(createHttpClient(true)) - suspend fun query(fullLink: String): PlatformQueryResult? { + suspend fun query(fullLink: String): SuspendableEvent = SuspendableEvent { var spotifyLink = "https://" + fullLink.substringAfterLast("https://").substringBefore(" ").trim() @@ -78,15 +80,16 @@ class SpotifyProvider( val type = spotifyLink.substringBeforeLast('/', "Error").substringAfterLast('/') if (type == "Error" || link == "Error") { - return null + throw SpotiFlyerException.LinkInvalid(fullLink) } if (type == "episode" || type == "show") { - // TODO Implementation - return null + throw SpotiFlyerException.FeatureNotImplementedYet( + "Support for Spotify's ${type.uppercase()} isn't implemented yet" + ) } - return try { + try { spotifySearch( type, link @@ -95,16 +98,11 @@ class SpotifyProvider( e.printStackTrace() // Try Reinitialising Client // Handle 401 Token Expiry ,etc Exceptions authenticateSpotifyClient(true) - // Retry Search - try { - spotifySearch( - type, - link - ) - } catch (e: Exception) { - e.printStackTrace() - null - } + + spotifySearch( + type, + link + ) } } @@ -112,15 +110,14 @@ class SpotifyProvider( type: String, link: String ): PlatformQueryResult { - val result = PlatformQueryResult( + return PlatformQueryResult( folderType = "", subFolder = "", title = "", coverUrl = "", trackList = listOf(), Source.Spotify - ) - with(result) { + ).apply { when (type) { "track" -> { getTrack(link).also { @@ -190,11 +187,10 @@ class SpotifyProvider( "show" -> { // TODO } else -> { - // TODO Handle Error + throw SpotiFlyerException.LinkInvalid("Provide: Spotify, Type:$type -> Link:$link") } } } - return result } /* diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMp3.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMp3.kt index 9bd488ea..582f492a 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMp3.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMp3.kt @@ -17,28 +17,28 @@ package com.shabinder.common.di.providers import co.touchlab.kermit.Kermit -import com.shabinder.common.di.Dir -import com.shabinder.common.di.currentPlatform +import com.shabinder.common.di.audioToMp3.AudioToMp3 import com.shabinder.common.di.youtubeMp3.Yt1sMp3 -import com.shabinder.common.models.AllPlatforms +import com.shabinder.common.models.corsApi +import com.shabinder.common.models.event.coroutines.SuspendableEvent +import com.shabinder.common.models.event.coroutines.map import io.ktor.client.* -class YoutubeMp3( - override val httpClient: HttpClient, - override val logger: Kermit, - private val dir: Dir, -) : Yt1sMp3 { - suspend fun getMp3DownloadLink(videoID: String): String? = try { - logger.i { "Youtube MP3 Link Fetching!" } - getLinkFromYt1sMp3(videoID)?.let { - logger.i { "Download Link: $it" } - if (currentPlatform is AllPlatforms.Js/* && corsProxy !is CorsProxy.PublicProxyWithExtension*/) - "https://cors.spotiflyer.ml/cors/$it" - // "https://spotiflyer.azurewebsites.net/$it" // Data OUT Limit issue - else it +interface YoutubeMp3: Yt1sMp3 { + + companion object { + operator fun invoke( + client: HttpClient, + logger: Kermit + ): AudioToMp3 { + return object : AudioToMp3 { + override val client: HttpClient = client + override val logger: Kermit = logger + } } - } catch (e: Exception) { - e.printStackTrace() - null + } + + suspend fun getMp3DownloadLink(videoID: String): SuspendableEvent = getLinkFromYt1sMp3(videoID).map { + corsApi + it } } diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMusic.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMusic.kt index 215a5ae5..195f615f 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMusic.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeMusic.kt @@ -18,15 +18,18 @@ package com.shabinder.common.di.providers import co.touchlab.kermit.Kermit import com.shabinder.common.di.audioToMp3.AudioToMp3 -import com.shabinder.common.di.gaana.corsApi +import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.YoutubeTrack +import com.shabinder.common.models.corsApi +import com.shabinder.common.models.event.coroutines.SuspendableEvent +import com.shabinder.common.models.event.coroutines.flatMap +import com.shabinder.common.models.event.coroutines.flatMapError +import com.shabinder.common.models.event.coroutines.map import io.github.shabinder.fuzzywuzzy.diffutils.FuzzySearch -import io.ktor.client.HttpClient -import io.ktor.client.request.headers -import io.ktor.client.request.post -import io.ktor.http.ContentType -import io.ktor.http.contentType +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.http.* import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.buildJsonArray @@ -37,6 +40,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject +import kotlin.collections.set import kotlin.math.absoluteValue class YoutubeMusic constructor( @@ -54,179 +58,178 @@ class YoutubeMusic constructor( suspend fun findSongDownloadURL( trackDetails: TrackDetails - ): String? { + ): SuspendableEvent { val bestMatchVideoID = getYTIDBestMatch(trackDetails) - return bestMatchVideoID?.let { videoID -> - youtubeMp3.getMp3DownloadLink(videoID) ?: youtubeProvider.ytDownloader?.getVideo(videoID)?.get()?.url?.let { m4aLink -> - audioToMp3.convertToMp3( - m4aLink - ) + return bestMatchVideoID.flatMap { videoID -> + // Get Downloadable Link + youtubeMp3.getMp3DownloadLink(videoID).flatMapError { + SuspendableEvent { + youtubeProvider.ytDownloader.getVideo(videoID).get()?.url?.let { m4aLink -> + audioToMp3.convertToMp3(m4aLink) + } ?: throw SpotiFlyerException.YoutubeLinkNotFound(videoID) + } } } } - suspend fun getYTIDBestMatch( + private suspend fun getYTIDBestMatch( trackDetails: TrackDetails - ): String? { - return try { + ):SuspendableEvent = + getYTTracks("${trackDetails.title} - ${trackDetails.artists.joinToString(",")}").map { matchList -> sortByBestMatch( - getYTTracks("${trackDetails.title} - ${trackDetails.artists.joinToString(",")}"), + matchList, trackName = trackDetails.title, trackArtists = trackDetails.artists, trackDurationSec = trackDetails.durationSec - ).keys.firstOrNull() - } catch (e: Exception) { - // All Internet/Client Related Errors - e.printStackTrace() - null + ).keys.firstOrNull() ?: throw SpotiFlyerException.NoMatchFound(trackDetails.title) } - } - private suspend fun getYTTracks(query: String): List { - val youtubeTracks = mutableListOf() - val responseObj = Json.parseToJsonElement(getYoutubeMusicResponse(query)) - logger.i { "Youtube Music Response Recieved" } - val contentBlocks = responseObj.jsonObject["contents"] - ?.jsonObject?.get("sectionListRenderer") - ?.jsonObject?.get("contents")?.jsonArray + private suspend fun getYTTracks(query: String): SuspendableEvent,Throwable> = + getYoutubeMusicResponse(query).map { youtubeResponseData -> + val youtubeTracks = mutableListOf() + val responseObj = Json.parseToJsonElement(youtubeResponseData) + // logger.i { "Youtube Music Response Received" } + val contentBlocks = responseObj.jsonObject["contents"] + ?.jsonObject?.get("sectionListRenderer") + ?.jsonObject?.get("contents")?.jsonArray - val resultBlocks = mutableListOf() - if (contentBlocks != null) { - for (cBlock in contentBlocks) { - /** - *Ignore user-suggestion - *The 'itemSectionRenderer' field is for user notices (stuff like - 'showing - *results for xyz, search for abc instead') we have no use for them, the for - *loop below if throw a keyError if we don't ignore them - */ - if (cBlock.jsonObject.containsKey("itemSectionRenderer")) { - continue - } - - for ( - contents in cBlock.jsonObject["musicShelfRenderer"]?.jsonObject?.get("contents")?.jsonArray - ?: listOf() - ) { + val resultBlocks = mutableListOf() + if (contentBlocks != null) { + for (cBlock in contentBlocks) { /** - * apparently content Blocks without an 'overlay' field don't have linkBlocks - * I have no clue what they are and why there even exist - * - if(!contents.containsKey("overlay")){ - println(contents) - continue - TODO check and correct - }*/ - - val result = contents.jsonObject["musicResponsiveListItemRenderer"] - ?.jsonObject?.get("flexColumns")?.jsonArray - - // Add the linkBlock - val linkBlock = contents.jsonObject["musicResponsiveListItemRenderer"] - ?.jsonObject?.get("overlay") - ?.jsonObject?.get("musicItemThumbnailOverlayRenderer") - ?.jsonObject?.get("content") - ?.jsonObject?.get("musicPlayButtonRenderer") - ?.jsonObject?.get("playNavigationEndpoint") - - // detailsBlock is always a list, so we just append the linkBlock to it - // instead of carrying along all the other junk from "musicResponsiveListItemRenderer" - val finalResult = buildJsonArray { - result?.let { add(it) } - linkBlock?.let { add(it) } + *Ignore user-suggestion + *The 'itemSectionRenderer' field is for user notices (stuff like - 'showing + *results for xyz, search for abc instead') we have no use for them, the for + *loop below if throw a keyError if we don't ignore them + */ + if (cBlock.jsonObject.containsKey("itemSectionRenderer")) { + continue + } + + for ( + contents in cBlock.jsonObject["musicShelfRenderer"]?.jsonObject?.get("contents")?.jsonArray + ?: listOf() + ) { + /** + * apparently content Blocks without an 'overlay' field don't have linkBlocks + * I have no clue what they are and why there even exist + * + if(!contents.containsKey("overlay")){ + println(contents) + continue + TODO check and correct + }*/ + + val result = contents.jsonObject["musicResponsiveListItemRenderer"] + ?.jsonObject?.get("flexColumns")?.jsonArray + + // Add the linkBlock + val linkBlock = contents.jsonObject["musicResponsiveListItemRenderer"] + ?.jsonObject?.get("overlay") + ?.jsonObject?.get("musicItemThumbnailOverlayRenderer") + ?.jsonObject?.get("content") + ?.jsonObject?.get("musicPlayButtonRenderer") + ?.jsonObject?.get("playNavigationEndpoint") + + // detailsBlock is always a list, so we just append the linkBlock to it + // instead of carrying along all the other junk from "musicResponsiveListItemRenderer" + val finalResult = buildJsonArray { + result?.let { add(it) } + linkBlock?.let { add(it) } + } + resultBlocks.add(finalResult) } - resultBlocks.add(finalResult) } - } - /* We only need results that are Songs or Videos, so we filter out the rest, since - ! Songs and Videos are supplied with different details, extracting all details from - ! both is just carrying on redundant data, so we also have to selectively extract - ! relevant details. What you need to know to understand how we do that here: - ! - ! Songs details are ALWAYS in the following order: - ! 0 - Name - ! 1 - Type (Song) - ! 2 - com.shabinder.spotiflyer.models.gaana.Artist - ! 3 - Album - ! 4 - Duration (mm:ss) - ! - ! Video details are ALWAYS in the following order: - ! 0 - Name - ! 1 - Type (Video) - ! 2 - Channel - ! 3 - Viewers - ! 4 - Duration (hh:mm:ss) - ! - ! We blindly gather all the details we get our hands on, then - ! cherry pick the details we need based on their index numbers, - ! we do so only if their Type is 'Song' or 'Video - */ - - for (result in resultBlocks) { - - // Blindly gather available details - val availableDetails = mutableListOf() - - /* - Filter Out dummies here itself - ! 'musicResponsiveListItemFlexColumnRenderer' should have more that one - ! sub-block, if not its a dummy, why does the YTM response contain dummies? - ! I have no clue. We skip these. - - ! Remember that we appended the linkBlock to result, treating that like the - ! other constituents of a result block will lead to errors, hence the 'in - ! result[:-1] ,i.e., skip last element in array ' + /* We only need results that are Songs or Videos, so we filter out the rest, since + ! Songs and Videos are supplied with different details, extracting all details from + ! both is just carrying on redundant data, so we also have to selectively extract + ! relevant details. What you need to know to understand how we do that here: + ! + ! Songs details are ALWAYS in the following order: + ! 0 - Name + ! 1 - Type (Song) + ! 2 - com.shabinder.spotiflyer.models.gaana.Artist + ! 3 - Album + ! 4 - Duration (mm:ss) + ! + ! Video details are ALWAYS in the following order: + ! 0 - Name + ! 1 - Type (Video) + ! 2 - Channel + ! 3 - Viewers + ! 4 - Duration (hh:mm:ss) + ! + ! We blindly gather all the details we get our hands on, then + ! cherry pick the details we need based on their index numbers, + ! we do so only if their Type is 'Song' or 'Video */ - for (detailArray in result.subList(0, result.size - 1)) { - for (detail in detailArray.jsonArray) { - if (detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]?.jsonObject?.size ?: 0 < 2) continue - // if not a dummy, collect All Variables - val details = detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"] - ?.jsonObject?.get("text") - ?.jsonObject?.get("runs")?.jsonArray ?: listOf() + for (result in resultBlocks) { - for (d in details) { - d.jsonObject["text"]?.jsonPrimitive?.contentOrNull?.let { - if (it != " • ") { - availableDetails.add(it) + // Blindly gather available details + val availableDetails = mutableListOf() + + /* + Filter Out dummies here itself + ! 'musicResponsiveListItemFlexColumnRenderer' should have more that one + ! sub-block, if not its a dummy, why does the YTM response contain dummies? + ! I have no clue. We skip these. + + ! Remember that we appended the linkBlock to result, treating that like the + ! other constituents of a result block will lead to errors, hence the 'in + ! result[:-1] ,i.e., skip last element in array ' + */ + for (detailArray in result.subList(0, result.size - 1)) { + for (detail in detailArray.jsonArray) { + if (detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]?.jsonObject?.size ?: 0 < 2) continue + + // if not a dummy, collect All Variables + val details = detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"] + ?.jsonObject?.get("text") + ?.jsonObject?.get("runs")?.jsonArray ?: listOf() + + for (d in details) { + d.jsonObject["text"]?.jsonPrimitive?.contentOrNull?.let { + if (it != " • ") { + availableDetails.add(it) + } } } } } - } - // logger.d("YT Music details"){availableDetails.toString()} - /* - ! Filter Out non-Song/Video results and incomplete results here itself - ! From what we know about detail order, note that [1] - indicate result type - */ - if (availableDetails.size == 5 && availableDetails[1] in listOf("Song", "Video")) { - - // skip if result is in hours instead of minutes (no song is that long) - if (availableDetails[4].split(':').size != 2) continue - + // logger.d("YT Music details"){availableDetails.toString()} /* - ! grab Video ID - ! this is nested as [playlistEndpoint/watchEndpoint][videoId/playlistId/...] - ! so hardcoding the dict keys for data look up is an ardours process, since - ! the sub-block pattern is fixed even though the key isn't, we just - ! reference the dict keys by index + ! Filter Out non-Song/Video results and incomplete results here itself + ! From what we know about detail order, note that [1] - indicate result type */ + if (availableDetails.size == 5 && availableDetails[1] in listOf("Song", "Video")) { - val videoId: String? = result.last().jsonObject["watchEndpoint"]?.jsonObject?.get("videoId")?.jsonPrimitive?.content - val ytTrack = YoutubeTrack( - name = availableDetails[0], - type = availableDetails[1], - artist = availableDetails[2], - duration = availableDetails[4], - videoId = videoId - ) - youtubeTracks.add(ytTrack) + // skip if result is in hours instead of minutes (no song is that long) + if (availableDetails[4].split(':').size != 2) continue + + /* + ! grab Video ID + ! this is nested as [playlistEndpoint/watchEndpoint][videoId/playlistId/...] + ! so hardcoding the dict keys for data look up is an ardours process, since + ! the sub-block pattern is fixed even though the key isn't, we just + ! reference the dict keys by index + */ + + val videoId: String? = result.last().jsonObject["watchEndpoint"]?.jsonObject?.get("videoId")?.jsonPrimitive?.content + val ytTrack = YoutubeTrack( + name = availableDetails[0], + type = availableDetails[1], + artist = availableDetails[2], + duration = availableDetails[4], + videoId = videoId + ) + youtubeTracks.add(ytTrack) + } } } - } // logger.d {youtubeTracks.joinToString("\n")} - return youtubeTracks + youtubeTracks } private fun sortByBestMatch( @@ -246,8 +249,8 @@ class YoutubeMusic constructor( // most song results on youtube go by $artist - $songName or artist1/artist2 var hasCommonWord = false - val resultName = result.name?.toLowerCase()?.replace("-", " ")?.replace("/", " ") ?: "" - val trackNameWords = trackName.toLowerCase().split(" ") + val resultName = result.name?.lowercase()?.replace("-", " ")?.replace("/", " ") ?: "" + val trackNameWords = trackName.lowercase().split(" ") for (nameWord in trackNameWords) { if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true @@ -266,12 +269,12 @@ class YoutubeMusic constructor( if (result.type == "Song") { for (artist in trackArtists) { - if (FuzzySearch.ratio(artist.toLowerCase(), result.artist?.toLowerCase() ?: "") > 85) + if (FuzzySearch.ratio(artist.lowercase(), result.artist?.lowercase() ?: "") > 85) artistMatchNumber++ } } else { // i.e. is a Video for (artist in trackArtists) { - if (FuzzySearch.partialRatio(artist.toLowerCase(), result.name?.toLowerCase() ?: "") > 85) + if (FuzzySearch.partialRatio(artist.lowercase(), result.name?.lowercase() ?: "") > 85) artistMatchNumber++ } } @@ -303,9 +306,8 @@ class YoutubeMusic constructor( } } - private suspend fun getYoutubeMusicResponse(query: String): String { - logger.i { "Fetching Youtube Music Response" } - return httpClient.post("${corsApi}https://music.youtube.com/youtubei/v1/search?alt=json&key=$apiKey") { + private suspend fun getYoutubeMusicResponse(query: String): SuspendableEvent = SuspendableEvent { + httpClient.post("${corsApi}https://music.youtube.com/youtubei/v1/search?alt=json&key=$apiKey") { contentType(ContentType.Application.Json) headers { append("referer", "https://music.youtube.com/search") diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeProvider.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeProvider.kt index 6f720ac8..8ae28336 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeProvider.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/providers/YoutubeProvider.kt @@ -22,7 +22,9 @@ import com.shabinder.common.di.finalOutputDir import com.shabinder.common.di.utils.removeIllegalChars import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.PlatformQueryResult +import com.shabinder.common.models.SpotiFlyerException import com.shabinder.common.models.TrackDetails +import com.shabinder.common.models.event.coroutines.SuspendableEvent import com.shabinder.common.models.spotify.Source import io.github.shabinder.YoutubeDownloader import io.github.shabinder.models.YoutubeVideo @@ -49,7 +51,7 @@ class YoutubeProvider( private val sampleDomain2 = "youtube.com" private val sampleDomain3 = "youtu.be" - suspend fun query(fullLink: String): PlatformQueryResult? { + suspend fun query(fullLink: String): SuspendableEvent { val link = fullLink.removePrefix("https://").removePrefix("http://") if (link.contains("playlist", true) || link.contains("list", true)) { // Given Link is of a Playlist @@ -77,74 +79,15 @@ class YoutubeProvider( ) } else { logger.d { "Your Youtube Link is not of a Video!!" } - null + SuspendableEvent.error(SpotiFlyerException.LinkInvalid(fullLink)) } } } private suspend fun getYTPlaylist( searchId: String - ): PlatformQueryResult? { - val result = PlatformQueryResult( - folderType = "", - subFolder = "", - title = "", - coverUrl = "", - trackList = listOf(), - Source.YouTube - ) - result.apply { - try { - val playlist = ytDownloader.getPlaylist(searchId) - val playlistDetails = playlist.details - val name = playlistDetails.title - subFolder = removeIllegalChars(name) - val videos = playlist.videos - - coverUrl = "https://i.ytimg.com/vi/${ - videos.firstOrNull()?.videoId - }/hqdefault.jpg" - title = name - - trackList = videos.map { - TrackDetails( - title = it.title ?: "N/A", - artists = listOf(it.author ?: "N/A"), - durationSec = it.lengthSeconds, - albumArtPath = dir.imageCacheDir() + it.videoId + ".jpeg", - source = Source.YouTube, - albumArtURL = "https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg", - downloaded = if (dir.isPresent( - dir.finalOutputDir( - itemName = it.title ?: "N/A", - type = folderType, - subFolder = subFolder, - dir.defaultDir() - ) - ) - ) - DownloadStatus.Downloaded - else { - DownloadStatus.NotDownloaded - }, - outputFilePath = dir.finalOutputDir(it.title ?: "N/A", folderType, subFolder, dir.defaultDir()/*,".m4a"*/), - videoID = it.videoId - ) - } - } catch (e: Exception) { - e.printStackTrace() - logger.d { "An Error Occurred While Processing!" } - } - } - return if (result.title.isNotBlank()) result - else null - } - - @Suppress("DefaultLocale") - private suspend fun getYTTrack( - searchId: String, - ): PlatformQueryResult? { - val result = PlatformQueryResult( + ): SuspendableEvent = SuspendableEvent { + PlatformQueryResult( folderType = "", subFolder = "", title = "", @@ -152,47 +95,90 @@ class YoutubeProvider( trackList = listOf(), Source.YouTube ).apply { - try { - logger.i { searchId } - val video = ytDownloader.getVideo(searchId) - coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg" - val detail = video.videoDetails - val name = detail.title?.replace(detail.author?.uppercase() ?: "", "", true) - ?: detail.title ?: "" - // logger.i{ detail.toString() } - trackList = listOf( - TrackDetails( - title = name, - artists = listOf(detail.author ?: "N/A"), - durationSec = detail.lengthSeconds, - albumArtPath = dir.imageCacheDir() + "$searchId.jpeg", - source = Source.YouTube, - albumArtURL = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg", - downloaded = if (dir.isPresent( - dir.finalOutputDir( - itemName = name, - type = folderType, - subFolder = subFolder, - defaultDir = dir.defaultDir() - ) + val playlist = ytDownloader.getPlaylist(searchId) + val playlistDetails = playlist.details + val name = playlistDetails.title + subFolder = removeIllegalChars(name) + val videos = playlist.videos + + coverUrl = "https://i.ytimg.com/vi/${ + videos.firstOrNull()?.videoId + }/hqdefault.jpg" + title = name + + trackList = videos.map { + TrackDetails( + title = it.title ?: "N/A", + artists = listOf(it.author ?: "N/A"), + durationSec = it.lengthSeconds, + albumArtPath = dir.imageCacheDir() + it.videoId + ".jpeg", + source = Source.YouTube, + albumArtURL = "https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg", + downloaded = if (dir.isPresent( + dir.finalOutputDir( + itemName = it.title ?: "N/A", + type = folderType, + subFolder = subFolder, + dir.defaultDir() ) ) - DownloadStatus.Downloaded - else { - DownloadStatus.NotDownloaded - }, - outputFilePath = dir.finalOutputDir(name, folderType, subFolder, dir.defaultDir()/*,".m4a"*/), - videoID = searchId ) + DownloadStatus.Downloaded + else { + DownloadStatus.NotDownloaded + }, + outputFilePath = dir.finalOutputDir(it.title ?: "N/A", folderType, subFolder, dir.defaultDir()/*,".m4a"*/), + videoID = it.videoId ) - title = name - } catch (e: Exception) { - e.printStackTrace() - logger.e { "An Error Occurred While Processing!,$searchId" } } } - return if (result.title.isNotBlank()) result - else null + } + + @Suppress("DefaultLocale") + private suspend fun getYTTrack( + searchId: String, + ): SuspendableEvent = SuspendableEvent { + PlatformQueryResult( + folderType = "", + subFolder = "", + title = "", + coverUrl = "", + trackList = listOf(), + Source.YouTube + ).apply { + val video = ytDownloader.getVideo(searchId) + coverUrl = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg" + val detail = video.videoDetails + val name = detail.title?.replace(detail.author?.uppercase() ?: "", "", true) + ?: detail.title ?: "" + // logger.i{ detail.toString() } + trackList = listOf( + TrackDetails( + title = name, + artists = listOf(detail.author ?: "N/A"), + durationSec = detail.lengthSeconds, + albumArtPath = dir.imageCacheDir() + "$searchId.jpeg", + source = Source.YouTube, + albumArtURL = "https://i.ytimg.com/vi/$searchId/hqdefault.jpg", + downloaded = if (dir.isPresent( + dir.finalOutputDir( + itemName = name, + type = folderType, + subFolder = subFolder, + defaultDir = dir.defaultDir() + ) + ) + ) + DownloadStatus.Downloaded + else { + DownloadStatus.NotDownloaded + }, + outputFilePath = dir.finalOutputDir(name, folderType, subFolder, dir.defaultDir()/*,".m4a"*/), + videoID = searchId + ) + ) + title = name + } } } diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/saavn/JioSaavnRequests.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/saavn/JioSaavnRequests.kt index 3c6ceb7c..b3b94675 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/saavn/JioSaavnRequests.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/saavn/JioSaavnRequests.kt @@ -2,8 +2,8 @@ package com.shabinder.common.di.saavn import co.touchlab.kermit.Kermit import com.shabinder.common.di.audioToMp3.AudioToMp3 -import com.shabinder.common.di.gaana.corsApi import com.shabinder.common.di.globalJson +import com.shabinder.common.models.corsApi import com.shabinder.common.models.saavn.SaavnAlbum import com.shabinder.common.models.saavn.SaavnPlaylist import com.shabinder.common.models.saavn.SaavnSearchResult @@ -13,9 +13,9 @@ 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.features.ServerResponseException -import io.ktor.client.request.get +import io.ktor.client.* +import io.ktor.client.features.* +import io.ktor.client.request.* import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -24,6 +24,7 @@ import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +import kotlin.collections.set interface JioSaavnRequests { @@ -237,8 +238,8 @@ interface JioSaavnRequests { for (result in tracks) { var hasCommonWord = false - val resultName = result.title.toLowerCase().replace("/", " ") - val trackNameWords = trackName.toLowerCase().split(" ") + val resultName = result.title.lowercase().replace("/", " ") + val trackNameWords = trackName.lowercase().split(" ") for (nameWord in trackNameWords) { if (nameWord.isNotBlank() && FuzzySearch.partialRatio(nameWord, resultName) > 85) hasCommonWord = true @@ -258,11 +259,11 @@ interface JioSaavnRequests { // String Containing All Artist Names from JioSaavn Search Result val artistListString = mutableSetOf().apply { result.more_info?.singers?.split(",")?.let { addAll(it) } - result.more_info?.primary_artists?.toLowerCase()?.split(",")?.let { addAll(it) } + result.more_info?.primary_artists?.lowercase()?.split(",")?.let { addAll(it) } }.joinToString(" , ") for (artist in trackArtists) { - if (FuzzySearch.partialRatio(artist.toLowerCase(), artistListString) > 85) + if (FuzzySearch.partialRatio(artist.lowercase(), artistListString) > 85) artistMatchNumber++ } diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyAuth.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyAuth.kt index 363eaf17..0bd18414 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyAuth.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyAuth.kt @@ -19,14 +19,14 @@ package com.shabinder.common.di.spotify import com.shabinder.common.di.globalJson import com.shabinder.common.models.methods import com.shabinder.common.models.spotify.TokenData -import io.ktor.client.HttpClient -import io.ktor.client.features.auth.Auth -import io.ktor.client.features.auth.providers.basic -import io.ktor.client.features.json.JsonFeature -import io.ktor.client.features.json.serializer.KotlinxSerializer -import io.ktor.client.request.forms.FormDataContent -import io.ktor.client.request.post -import io.ktor.http.Parameters +import io.ktor.client.* +import io.ktor.client.features.auth.* +import io.ktor.client.features.auth.providers.* +import io.ktor.client.features.json.* +import io.ktor.client.features.json.serializer.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.http.* import kotlin.native.concurrent.SharedImmutable suspend fun authenticateSpotify(): TokenData? { @@ -48,9 +48,10 @@ private val spotifyAuthClient by lazy { install(Auth) { basic { - sendWithoutRequest = true - username = clientId - password = clientSecret + sendWithoutRequest { true } + credentials { + BasicAuthCredentials(clientId, clientSecret) + } } } install(JsonFeature) { diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyRequests.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyRequests.kt index dba7784e..22acff05 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyRequests.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/spotify/SpotifyRequests.kt @@ -16,14 +16,16 @@ package com.shabinder.common.di.spotify -import com.shabinder.common.di.gaana.corsApi import com.shabinder.common.models.NativeAtomicReference +import com.shabinder.common.models.corsApi import com.shabinder.common.models.spotify.Album import com.shabinder.common.models.spotify.PagingObjectPlaylistTrack import com.shabinder.common.models.spotify.Playlist import com.shabinder.common.models.spotify.Track -import io.ktor.client.HttpClient -import io.ktor.client.request.get +import io.github.shabinder.TargetPlatforms +import io.github.shabinder.activePlatform +import io.ktor.client.* +import io.ktor.client.request.* private val BASE_URL get() = "${corsApi}https://api.spotify.com/v1" @@ -32,7 +34,7 @@ interface SpotifyRequests { val httpClientRef: NativeAtomicReference val httpClient: HttpClient get() = httpClientRef.value - suspend fun authenticateSpotifyClient(override: Boolean = false) + suspend fun authenticateSpotifyClient(override: Boolean = activePlatform is TargetPlatforms.Js) suspend fun getPlaylist(playlistID: String): Playlist { return httpClient.get("$BASE_URL/playlists/$playlistID") diff --git a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/youtubeMp3/Yt1sMp3.kt b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/youtubeMp3/Yt1sMp3.kt index 4a1613a5..f965c6bc 100644 --- a/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/youtubeMp3/Yt1sMp3.kt +++ b/common/dependency-injection/src/commonMain/kotlin/com/shabinder/common/di/youtubeMp3/Yt1sMp3.kt @@ -17,11 +17,13 @@ package com.shabinder.common.di.youtubeMp3 import co.touchlab.kermit.Kermit -import com.shabinder.common.di.gaana.corsApi -import io.ktor.client.HttpClient -import io.ktor.client.request.forms.FormDataContent -import io.ktor.client.request.post -import io.ktor.http.Parameters +import com.shabinder.common.models.corsApi +import com.shabinder.common.models.event.coroutines.SuspendableEvent +import com.shabinder.common.requireNotNull +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.http.* import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive @@ -33,18 +35,24 @@ interface Yt1sMp3 { val httpClient: HttpClient val logger: Kermit + /* * Downloadable Mp3 Link for YT videoID. * */ - suspend fun getLinkFromYt1sMp3(videoID: String): String? = - getConvertedMp3Link(videoID, getKey(videoID))?.get("dlink")?.jsonPrimitive?.toString()?.replace("\"", "") + suspend fun getLinkFromYt1sMp3(videoID: String): SuspendableEvent = SuspendableEvent { + getConvertedMp3Link( + videoID, + getKey(videoID).value + ).value["dlink"].requireNotNull() + .jsonPrimitive.content.replace("\"", "") + } /* * POST:https://yt1s.com/api/ajaxSearch/index * Body Form= q:yt video link ,vt:format=mp3 * */ - private suspend fun getKey(videoID: String): String { - val response: JsonObject? = httpClient.post("${corsApi}https://yt1s.com/api/ajaxSearch/index") { + private suspend fun getKey(videoID: String): SuspendableEvent = SuspendableEvent { + val response: JsonObject = httpClient.post("${corsApi}https://yt1s.com/api/ajaxSearch/index") { body = FormDataContent( Parameters.build { append("q", "https://www.youtube.com/watch?v=$videoID") @@ -52,11 +60,12 @@ interface Yt1sMp3 { } ) } - return response?.get("kc")?.jsonPrimitive.toString() + + response["kc"].requireNotNull().jsonPrimitive.content } - private suspend fun getConvertedMp3Link(videoID: String, key: String): JsonObject? { - return httpClient.post("${corsApi}https://yt1s.com/api/ajaxConvert/convert") { + private suspend fun getConvertedMp3Link(videoID: String, key: String): SuspendableEvent = SuspendableEvent { + httpClient.post("${corsApi}https://yt1s.com/api/ajaxConvert/convert") { body = FormDataContent( Parameters.build { append("vid", videoID) diff --git a/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/di/DesktopActual.kt b/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/di/DesktopActual.kt index 6bf00c69..cbc449d7 100644 --- a/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/di/DesktopActual.kt +++ b/common/dependency-injection/src/desktopMain/kotlin/com/shabinder/common/di/DesktopActual.kt @@ -17,7 +17,6 @@ package com.shabinder.common.di import com.shabinder.common.di.utils.ParallelExecutor -import com.shabinder.common.models.AllPlatforms import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.TrackDetails @@ -34,9 +33,6 @@ val DownloadScope = ParallelExecutor(Dispatchers.IO) // IO-Dispatcher actual val dispatcherIO: CoroutineDispatcher = Dispatchers.IO -// Current Platform Info -actual val currentPlatform: AllPlatforms = AllPlatforms.Jvm - actual suspend fun downloadTracks( list: List, fetcher: FetchPlatformQueryResult, diff --git a/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebActual.kt b/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebActual.kt index 7a01defa..5c0740a1 100644 --- a/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebActual.kt +++ b/common/dependency-injection/src/jsMain/kotlin/com/shabinder/common/di/WebActual.kt @@ -16,7 +16,6 @@ package com.shabinder.common.di -import com.shabinder.common.models.AllPlatforms import com.shabinder.common.models.DownloadResult import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.TrackDetails @@ -34,9 +33,6 @@ val allTracksStatus: HashMap = hashMapOf() // IO-Dispatcher actual val dispatcherIO: CoroutineDispatcher = Dispatchers.Default -// Current Platform Info -actual val currentPlatform: AllPlatforms = AllPlatforms.Js - actual suspend fun downloadTracks( list: List, fetcher: FetchPlatformQueryResult, diff --git a/common/root/src/commonMain/kotlin/com/shabinder/common/root/integration/SpotiFlyerRootImpl.kt b/common/root/src/commonMain/kotlin/com/shabinder/common/root/integration/SpotiFlyerRootImpl.kt index a0921a43..d322659a 100644 --- a/common/root/src/commonMain/kotlin/com/shabinder/common/root/integration/SpotiFlyerRootImpl.kt +++ b/common/root/src/commonMain/kotlin/com/shabinder/common/root/integration/SpotiFlyerRootImpl.kt @@ -28,12 +28,10 @@ import com.arkivanov.decompose.statekeeper.Parcelable import com.arkivanov.decompose.statekeeper.Parcelize import com.arkivanov.decompose.value.Value import com.shabinder.common.di.Dir -import com.shabinder.common.di.currentPlatform import com.shabinder.common.di.providers.SpotifyProvider import com.shabinder.common.list.SpotiFlyerList import com.shabinder.common.main.SpotiFlyerMain import com.shabinder.common.models.Actions -import com.shabinder.common.models.AllPlatforms import com.shabinder.common.models.Consumer import com.shabinder.common.models.methods import com.shabinder.common.root.SpotiFlyerRoot @@ -80,10 +78,7 @@ internal class SpotiFlyerRootImpl( instanceKeeper.ensureNeverFrozen() methods.value = dependencies.actions.freeze() /*Authenticate Spotify Client*/ - authenticateSpotify( - dependencies.fetchPlatformQueryResult.spotifyProvider, - currentPlatform is AllPlatforms.Js - ) + authenticateSpotify(dependencies.fetchPlatformQueryResult.spotifyProvider) } private val router = @@ -134,11 +129,11 @@ internal class SpotiFlyerRootImpl( } } - private fun authenticateSpotify(spotifyProvider: SpotifyProvider, override: Boolean) { + private fun authenticateSpotify(spotifyProvider: SpotifyProvider) { GlobalScope.launch(Dispatchers.Default) { analytics.appLaunchEvent() /*Authenticate Spotify Client*/ - spotifyProvider.authenticateSpotifyClient(override) + spotifyProvider.authenticateSpotifyClient() } }