mirror of
https://github.com/Shabinder/SpotiFlyer.git
synced 2024-11-27 19:04:32 +01:00
SoundCloud Parsing Fixes and HttpClient SSL Certificates Validation Ignore
This commit is contained in:
parent
d5838db298
commit
9b3b00f0d2
@ -55,6 +55,7 @@ android {
|
|||||||
targetSdk = Versions.targetSdkVersion
|
targetSdk = Versions.targetSdkVersion
|
||||||
versionCode = Versions.versionCode
|
versionCode = Versions.versionCode
|
||||||
versionName = Versions.versionName
|
versionName = Versions.versionName
|
||||||
|
ndkVersion = "21.4.7075529"
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("release") {
|
getByName("release") {
|
||||||
|
@ -26,9 +26,9 @@ import org.gradle.kotlin.dsl.getByType
|
|||||||
|
|
||||||
object Versions {
|
object Versions {
|
||||||
// App's Version (To be bumped at each update)
|
// App's Version (To be bumped at each update)
|
||||||
const val versionName = "3.5.0"
|
const val versionName = "3.6.0"
|
||||||
|
|
||||||
const val versionCode = 27
|
const val versionCode = 28
|
||||||
|
|
||||||
// Android
|
// Android
|
||||||
const val minSdkVersion = 21
|
const val minSdkVersion = 21
|
||||||
@ -78,6 +78,7 @@ val VersionCatalog.kotlinCoroutines get() = findDependency("kotlin-coroutines").
|
|||||||
val VersionCatalog.kotlinxSerialization get() = findDependency("kotlinx-serialization-json").get()
|
val VersionCatalog.kotlinxSerialization get() = findDependency("kotlinx-serialization-json").get()
|
||||||
val VersionCatalog.ktorClientIOS get() = findDependency("ktor-client-ios").get()
|
val VersionCatalog.ktorClientIOS get() = findDependency("ktor-client-ios").get()
|
||||||
val VersionCatalog.ktorClientAndroid get() = findDependency("ktor-client-android").get()
|
val VersionCatalog.ktorClientAndroid get() = findDependency("ktor-client-android").get()
|
||||||
|
val VersionCatalog.ktorClientAndroidOkHttp get() = findDependency("ktor-client-okhttp").get()
|
||||||
val VersionCatalog.ktorClientApache get() = findDependency("ktor-client-apache").get()
|
val VersionCatalog.ktorClientApache get() = findDependency("ktor-client-apache").get()
|
||||||
val VersionCatalog.ktorClientJS get() = findDependency("ktor-client-js").get()
|
val VersionCatalog.ktorClientJS get() = findDependency("ktor-client-js").get()
|
||||||
val VersionCatalog.ktorClientCIO get() = findDependency("ktor-client-cio").get()
|
val VersionCatalog.ktorClientCIO get() = findDependency("ktor-client-cio").get()
|
||||||
|
@ -58,6 +58,7 @@ ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version
|
|||||||
ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization", version.ref = "ktor" }
|
ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization", version.ref = "ktor" }
|
||||||
ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
|
ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
|
||||||
ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" }
|
ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" }
|
||||||
|
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
|
||||||
ktor-client-curl = { group = "io.ktor", name = "ktor-client-curl", version.ref = "ktor" }
|
ktor-client-curl = { group = "io.ktor", name = "ktor-client-curl", version.ref = "ktor" }
|
||||||
ktor-client-apache = { group = "io.ktor", name = "ktor-client-apache", version.ref = "ktor" }
|
ktor-client-apache = { group = "io.ktor", name = "ktor-client-apache", version.ref = "ktor" }
|
||||||
ktor-client-ios = { group = "io.ktor", name = "ktor-client-ios", version.ref = "ktor" }
|
ktor-client-ios = { group = "io.ktor", name = "ktor-client-ios", version.ref = "ktor" }
|
||||||
|
@ -67,7 +67,7 @@ kotlin {
|
|||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
implementation(Deps.androidXCommonBundle)
|
implementation(Deps.androidXCommonBundle)
|
||||||
implementation(Deps.decomposeComposeExt)
|
implementation(Deps.decomposeComposeExt)
|
||||||
implementation(Deps.ktorClientAndroid)
|
implementation(Deps.ktorClientAndroidOkHttp)
|
||||||
implementation(Deps.koinAndroidBundle)
|
implementation(Deps.koinAndroidBundle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -210,7 +210,7 @@ class AndroidFileManager(
|
|||||||
// Get Memory Efficient Bitmap
|
// Get Memory Efficient Bitmap
|
||||||
val bitmap: Bitmap? = getMemoryEfficientBitmap(input, reqWidth, reqHeight)
|
val bitmap: Bitmap? = getMemoryEfficientBitmap(input, reqWidth, reqHeight)
|
||||||
|
|
||||||
parallelExecutor.executeSuspending {
|
parallelExecutor.execute {
|
||||||
// Decode and Cache Full Sized Image in Background
|
// Decode and Cache Full Sized Image in Background
|
||||||
cacheImage(
|
cacheImage(
|
||||||
BitmapFactory.decodeByteArray(input, 0, input.size),
|
BitmapFactory.decodeByteArray(input, 0, input.size),
|
||||||
|
@ -40,7 +40,7 @@ fun Mp3File.setId3v1Tags(track: TrackDetails): Mp3File {
|
|||||||
title = track.title
|
title = track.title
|
||||||
album = track.albumName
|
album = track.albumName
|
||||||
year = track.year
|
year = track.year
|
||||||
comment = "Genres:${track.comment}"
|
comment = "${track.comment}"
|
||||||
if (track.trackNumber != null)
|
if (track.trackNumber != null)
|
||||||
this.track = track.trackNumber.toString()
|
this.track = track.trackNumber.toString()
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,55 @@
|
|||||||
|
package com.shabinder.common.core_components.utils
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.HttpClientConfig
|
||||||
|
import io.ktor.client.engine.HttpClientEngineConfig
|
||||||
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
import io.ktor.client.engine.okhttp.OkHttpConfig
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.TrustManager
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
actual fun buildHttpClient(extraConfig: HttpClientConfig<*>.() -> Unit): HttpClient {
|
||||||
|
return HttpClient(OkHttp) {
|
||||||
|
engine {
|
||||||
|
preconfigured = getUnsafeOkHttpClient()
|
||||||
|
}
|
||||||
|
extraConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUnsafeOkHttpClient(): OkHttpClient {
|
||||||
|
return try {
|
||||||
|
// Create a trust manager that does not validate certificate chains
|
||||||
|
@SuppressLint("CustomX509TrustManager")
|
||||||
|
val trustAllCerts: TrustManager = object : X509TrustManager {
|
||||||
|
@SuppressLint("TrustAllX509TrustManager")
|
||||||
|
override fun checkClientTrusted(chain: Array<X509Certificate?>?, authType: String?) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("TrustAllX509TrustManager")
|
||||||
|
override fun checkServerTrusted(chain: Array<X509Certificate?>?, authType: String?) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install the all-trusting trust manager
|
||||||
|
val sslContext: SSLContext = SSLContext.getInstance("SSL").apply {
|
||||||
|
init(null, arrayOf(trustAllCerts), SecureRandom())
|
||||||
|
}
|
||||||
|
|
||||||
|
OkHttpClient.Builder().run {
|
||||||
|
sslSocketFactory(sslContext.socketFactory, trustAllCerts as X509TrustManager)
|
||||||
|
hostnameVerifier { _, _ -> true }
|
||||||
|
followRedirects(true)
|
||||||
|
build()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw RuntimeException(e)
|
||||||
|
}
|
||||||
|
}
|
@ -40,15 +40,6 @@ interface ParallelProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun <T> executeSafelyInPool(
|
|
||||||
onComplete: suspend (result: SuspendableEvent<T, Throwable>) -> Unit = {},
|
|
||||||
block: suspend () -> T
|
|
||||||
): SuspendableEvent<T, Throwable> {
|
|
||||||
return SuspendableEvent {
|
|
||||||
parallelExecutor.executeSuspending(block)
|
|
||||||
}.also { onComplete(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun stopAllTasks() {
|
suspend fun stopAllTasks() {
|
||||||
parallelExecutor.closeAndReInit()
|
parallelExecutor.closeAndReInit()
|
||||||
}
|
}
|
||||||
|
@ -2,14 +2,20 @@ package com.shabinder.common.core_components.utils
|
|||||||
|
|
||||||
import com.shabinder.common.models.dispatcherIO
|
import com.shabinder.common.models.dispatcherIO
|
||||||
import com.shabinder.common.utils.globalJson
|
import com.shabinder.common.utils.globalJson
|
||||||
import io.ktor.client.*
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.features.*
|
import io.ktor.client.HttpClientConfig
|
||||||
import io.ktor.client.features.json.*
|
import io.ktor.client.features.HttpTimeout
|
||||||
import io.ktor.client.features.json.serializer.*
|
import io.ktor.client.features.json.JsonFeature
|
||||||
import io.ktor.client.features.logging.*
|
import io.ktor.client.features.json.serializer.KotlinxSerializer
|
||||||
import io.ktor.client.request.*
|
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.request.HttpRequestBuilder
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.head
|
||||||
import io.ktor.client.statement.HttpResponse
|
import io.ktor.client.statement.HttpResponse
|
||||||
import kotlinx.coroutines.Dispatchers
|
import io.ktor.http.ContentType
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.native.concurrent.SharedImmutable
|
import kotlin.native.concurrent.SharedImmutable
|
||||||
|
|
||||||
@ -32,39 +38,42 @@ suspend inline fun HttpClient.getFinalUrl(
|
|||||||
): String {
|
): String {
|
||||||
return withContext(dispatcherIO) {
|
return withContext(dispatcherIO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
get<HttpResponse>(url,block).call.request.url.toString()
|
get<HttpResponse>(url, block).call.request.url.toString()
|
||||||
}.getOrNull() ?: url
|
}.getOrNull() ?: url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createHttpClient(enableNetworkLogs: Boolean = false) = HttpClient {
|
fun createHttpClient(enableNetworkLogs: Boolean = false) = HttpClient {
|
||||||
// https://github.com/Kotlin/kotlinx.serialization/issues/1450
|
buildHttpClient {
|
||||||
install(JsonFeature) {
|
// https://github.com/Kotlin/kotlinx.serialization/issues/1450
|
||||||
serializer = KotlinxSerializer(globalJson)
|
install(JsonFeature) {
|
||||||
}
|
serializer = KotlinxSerializer(globalJson)
|
||||||
install(HttpTimeout) {
|
}
|
||||||
socketTimeoutMillis = 520_000
|
install(HttpTimeout) {
|
||||||
requestTimeoutMillis = 360_000
|
socketTimeoutMillis = 520_000
|
||||||
connectTimeoutMillis = 360_000
|
requestTimeoutMillis = 360_000
|
||||||
}
|
connectTimeoutMillis = 360_000
|
||||||
// WorkAround for Freezing
|
}
|
||||||
// Use httpClient.getData / httpClient.postData Extensions
|
// WorkAround for Freezing
|
||||||
/*install(JsonFeature) {
|
// Use httpClient.getData / httpClient.postData Extensions
|
||||||
serializer = KotlinxSerializer(
|
/*install(JsonFeature) {
|
||||||
Json {
|
serializer = KotlinxSerializer(
|
||||||
isLenient = true
|
Json {
|
||||||
ignoreUnknownKeys = true
|
isLenient = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}*/
|
||||||
|
if (enableNetworkLogs) {
|
||||||
|
install(Logging) {
|
||||||
|
logger = Logger.DEFAULT
|
||||||
|
level = LogLevel.INFO
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}*/
|
|
||||||
if (enableNetworkLogs) {
|
|
||||||
install(Logging) {
|
|
||||||
logger = Logger.DEFAULT
|
|
||||||
level = LogLevel.INFO
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expect fun buildHttpClient(extraConfig: HttpClientConfig<*>.() -> Unit): HttpClient
|
||||||
|
|
||||||
/*Client Active Throughout App's Lifetime*/
|
/*Client Active Throughout App's Lifetime*/
|
||||||
@SharedImmutable
|
@SharedImmutable
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
package com.shabinder.common.core_components.utils
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.HttpClientConfig
|
||||||
|
import io.ktor.client.engine.apache.Apache
|
||||||
|
import org.apache.http.conn.ssl.NoopHostnameVerifier
|
||||||
|
import org.apache.http.conn.ssl.TrustSelfSignedStrategy
|
||||||
|
import org.apache.http.ssl.SSLContextBuilder
|
||||||
|
|
||||||
|
actual fun buildHttpClient(extraConfig: HttpClientConfig<*>.() -> Unit): HttpClient {
|
||||||
|
return HttpClient(Apache) {
|
||||||
|
engine {
|
||||||
|
customizeClient {
|
||||||
|
setSSLContext(
|
||||||
|
SSLContextBuilder
|
||||||
|
.create()
|
||||||
|
.loadTrustMaterial(TrustSelfSignedStrategy())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
setSSLHostnameVerifier(NoopHostnameVerifier())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extraConfig()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package com.shabinder.common.core_components.utils
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.HttpClientConfig
|
||||||
|
import io.ktor.client.engine.js.Js
|
||||||
|
|
||||||
|
actual fun buildHttpClient(extraConfig: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(Js) {
|
||||||
|
extraConfig()
|
||||||
|
}
|
@ -31,7 +31,7 @@ sealed class SoundCloudResolveResponseBase {
|
|||||||
@SerialName("embeddable_by")
|
@SerialName("embeddable_by")
|
||||||
val embeddableBy: String = "",
|
val embeddableBy: String = "",
|
||||||
val genre: String = "",
|
val genre: String = "",
|
||||||
val id: Int = 0,
|
val id: String = "",
|
||||||
@SerialName("is_album")
|
@SerialName("is_album")
|
||||||
val isAlbum: Boolean = false,
|
val isAlbum: Boolean = false,
|
||||||
@SerialName("label_name")
|
@SerialName("label_name")
|
||||||
@ -100,7 +100,7 @@ sealed class SoundCloudResolveResponseBase {
|
|||||||
val genre: String = "",
|
val genre: String = "",
|
||||||
@SerialName("has_downloads_left")
|
@SerialName("has_downloads_left")
|
||||||
val hasDownloadsLeft: Boolean = false,
|
val hasDownloadsLeft: Boolean = false,
|
||||||
val id: Int = 0,
|
val id: String = "",
|
||||||
override val kind: String = "",
|
override val kind: String = "",
|
||||||
@SerialName("label_name")
|
@SerialName("label_name")
|
||||||
val labelName: String = "",
|
val labelName: String = "",
|
||||||
|
@ -16,5 +16,5 @@ val globalJson by lazy {
|
|||||||
* Removing Illegal Chars from File Name
|
* Removing Illegal Chars from File Name
|
||||||
* **/
|
* **/
|
||||||
fun removeIllegalChars(fileName: String): String {
|
fun removeIllegalChars(fileName: String): String {
|
||||||
return fileName.replace("[^\\dA-Za-z_]".toRegex(), "_")
|
return fileName.replace("[^\\dA-Za-z0-9-_]".toRegex(), "_")
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import com.shabinder.common.models.SpotiFlyerException
|
|||||||
import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase
|
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.SoundCloudResolveResponsePlaylist
|
||||||
import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase.SoundCloudResolveResponseTrack
|
import com.shabinder.common.models.soundcloud.resolvemodel.SoundCloudResolveResponseBase.SoundCloudResolveResponseTrack
|
||||||
|
import com.shabinder.common.utils.globalJson
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.features.ClientRequestException
|
import io.ktor.client.features.ClientRequestException
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
@ -12,7 +13,13 @@ import io.ktor.client.request.parameter
|
|||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import kotlinx.serialization.DeserializationStrategy
|
||||||
import kotlinx.serialization.InternalSerializationApi
|
import kotlinx.serialization.InternalSerializationApi
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
interface SoundCloudRequests {
|
interface SoundCloudRequests {
|
||||||
|
|
||||||
@ -74,18 +81,21 @@ interface SoundCloudRequests {
|
|||||||
if (media.transcodings.isNotEmpty())
|
if (media.transcodings.isNotEmpty())
|
||||||
return this
|
return this
|
||||||
|
|
||||||
val infoURL = URLS.TRACK_INFO.buildURL(id.toString())
|
val infoURL = URLS.TRACK_INFO.buildURL(id)
|
||||||
return httpClient.get(infoURL) {
|
|
||||||
|
val data: String = httpClient.get(infoURL) {
|
||||||
parameter("client_id", CLIENT_ID)
|
parameter("client_id", CLIENT_ID)
|
||||||
}
|
}
|
||||||
|
return globalJson.decodeFromString(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private 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 itemURL = URLS.RESOLVE.buildURL(url)
|
||||||
val resp: SoundCloudResolveResponseBase = try {
|
val resp: SoundCloudResolveResponseBase = try {
|
||||||
httpClient.get(itemURL) {
|
val data: String = httpClient.get(itemURL) {
|
||||||
parameter("client_id", clientID)
|
parameter("client_id", clientID)
|
||||||
}
|
}
|
||||||
|
globalJson.decodeFromString(SoundCloudSerializer, data)
|
||||||
} catch (e: ClientRequestException) {
|
} catch (e: ClientRequestException) {
|
||||||
if (clientID != ALT_CLIENT_ID)
|
if (clientID != ALT_CLIENT_ID)
|
||||||
return getResponseObj(url, ALT_CLIENT_ID)
|
return getResponseObj(url, ALT_CLIENT_ID)
|
||||||
@ -116,6 +126,25 @@ interface SoundCloudRequests {
|
|||||||
|
|
||||||
const val CLIENT_ID = "a3e059563d7fd3372b49b37f00a00bcf"
|
const val CLIENT_ID = "a3e059563d7fd3372b49b37f00a00bcf"
|
||||||
const val ALT_CLIENT_ID = "2t9loNQH90kzJcsFCODdigxfp325aq4z"
|
const val ALT_CLIENT_ID = "2t9loNQH90kzJcsFCODdigxfp325aq4z"
|
||||||
|
|
||||||
|
object SoundCloudSerializer :
|
||||||
|
JsonContentPolymorphicSerializer<SoundCloudResolveResponseBase>(SoundCloudResolveResponseBase::class) {
|
||||||
|
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<out SoundCloudResolveResponseBase> =
|
||||||
|
when {
|
||||||
|
"track_count" in element.jsonObject -> SoundCloudResolveResponsePlaylist.serializer()
|
||||||
|
"kind" in element.jsonObject -> {
|
||||||
|
val isTrack =
|
||||||
|
element.jsonObject["kind"]
|
||||||
|
?.jsonPrimitive?.content.toString()
|
||||||
|
.contains("track", true)
|
||||||
|
when {
|
||||||
|
isTrack || "track_format" in element.jsonObject -> SoundCloudResolveResponseTrack.serializer()
|
||||||
|
else -> SoundCloudResolveResponsePlaylist.serializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> SoundCloudResolveResponsePlaylist.serializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user