Use ktor instead of fuel for http requests [Part 1/2]

This commit is contained in:
Jannik 2022-03-05 19:22:47 +01:00
parent 4505f95309
commit 2016e03e56
Signed by: Seil0
GPG Key ID: E8459F3723C52C24
3 changed files with 203 additions and 109 deletions

View File

@ -79,6 +79,7 @@ dependencies {
// TODO replace fuel with ktor // TODO replace fuel with ktor
implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-android:$ktor_version" implementation "io.ktor:ktor-client-android:$ktor_version"
implementation "io.ktor:ktor-client-serialization:$ktor_version"
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.ext:junit:1.1.3'

View File

@ -8,12 +8,15 @@ import com.github.kittinunf.fuel.json.FuelJson
import com.github.kittinunf.fuel.json.responseJson import com.github.kittinunf.fuel.json.responseJson
import com.github.kittinunf.result.Result import com.github.kittinunf.result.Result
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import org.mosad.teapod.preferences.EncryptedPreferences import org.mosad.teapod.preferences.EncryptedPreferences
@ -25,7 +28,11 @@ private val json = Json { ignoreUnknownKeys = true }
object Crunchyroll { object Crunchyroll {
private val TAG = javaClass.name private val TAG = javaClass.name
private val client = HttpClient() private val client = HttpClient {
install(JsonFeature) {
serializer = KotlinxSerializer(json)
}
}
private const val baseUrl = "https://beta-api.crunchyroll.com" private const val baseUrl = "https://beta-api.crunchyroll.com"
private var accessToken = "" private var accessToken = ""
@ -100,31 +107,56 @@ object Crunchyroll {
* Requests: get, post, delete * Requests: get, post, delete
*/ */
private suspend fun request( private suspend inline fun <reified T> request(
url: String,
httpMethod: HttpMethod,
params: Parameters = listOf(),
bodyA: Any = Any()
): T = coroutineScope {
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
return@coroutineScope (Dispatchers.IO) {
val response: T = client.request(url) {
method = httpMethod
body = bodyA
header("Authorization", "$tokenType $accessToken")
params.forEach {
parameter(it.first, it.second)
}
// for json body set content type
if (bodyA is JsonObject) {
contentType(ContentType.Application.Json)
}
}
response
}
}
private suspend inline fun <reified T> requestGet(
endpoint: String, endpoint: String,
params: Parameters = listOf(), params: Parameters = listOf(),
url: String = "" url: String = ""
): Result<FuelJson, FuelError> = coroutineScope { ): T = coroutineScope {
val path = url.ifEmpty { "$baseUrl$endpoint" } val path = url.ifEmpty { "$baseUrl$endpoint" }
if (System.currentTimeMillis() > tokenValidUntil) refreshToken() if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
return@coroutineScope (Dispatchers.IO) { return@coroutineScope (Dispatchers.IO) {
val (request, response, result) = Fuel.get(path, params) client.request(path) {
.header("Authorization", "$tokenType $accessToken") method = HttpMethod.Get
.responseJson() header("Authorization", "$tokenType $accessToken")
params.forEach {
// println("request request: $request") parameter(it.first, it.second)
// println("request response: $response") }
// println("request result: $result") } as T
result
} }
} }
private suspend fun requestPost( private suspend fun requestPost(
endpoint: String, endpoint: String,
params: Parameters = listOf(), params: Parameters = listOf(),
requestBody: String bodyObject: JsonObject
) = coroutineScope { ) = coroutineScope {
val path = "$baseUrl$endpoint" val path = "$baseUrl$endpoint"
if (System.currentTimeMillis() > tokenValidUntil) refreshToken() if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
@ -132,7 +164,7 @@ object Crunchyroll {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val response: HttpResponse = client.request(path) { val response: HttpResponse = client.request(path) {
method = HttpMethod.Post method = HttpMethod.Post
body = requestBody body = bodyObject
header("Authorization", "$tokenType $accessToken") header("Authorization", "$tokenType $accessToken")
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
params.forEach { params.forEach {
@ -140,14 +172,14 @@ object Crunchyroll {
} }
} }
Log.i(TAG, "Response status: ${response.status}") Log.i(TAG, "Response: $response")
} }
} }
private suspend fun requestPatch( private suspend fun requestPatch(
endpoint: String, endpoint: String,
params: Parameters = listOf(), params: Parameters = listOf(),
requestBody: String bodyObject: JsonObject
) = coroutineScope { ) = coroutineScope {
val path = "$baseUrl$endpoint" val path = "$baseUrl$endpoint"
if (System.currentTimeMillis() > tokenValidUntil) refreshToken() if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
@ -155,7 +187,7 @@ object Crunchyroll {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val response: HttpResponse = client.request(path) { val response: HttpResponse = client.request(path) {
method = HttpMethod.Patch method = HttpMethod.Patch
body = requestBody body = bodyObject
header("Authorization", "$tokenType $accessToken") header("Authorization", "$tokenType $accessToken")
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
params.forEach { params.forEach {
@ -163,7 +195,7 @@ object Crunchyroll {
} }
} }
Log.i(TAG, "Response status: ${response.status}") Log.i(TAG, "Response: $response")
} }
} }
@ -176,9 +208,15 @@ object Crunchyroll {
if (System.currentTimeMillis() > tokenValidUntil) refreshToken() if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Fuel.delete(path, params) val response: HttpResponse = client.request(path) {
.header("Authorization", "$tokenType $accessToken") method = HttpMethod.Delete
.response() // without a response, crunchy doesn't accept the request header("Authorization", "$tokenType $accessToken")
params.forEach {
parameter(it.first, it.second)
}
}
Log.i(TAG, "Response : $response")
} }
} }
@ -193,17 +231,15 @@ object Crunchyroll {
*/ */
suspend fun index() { suspend fun index() {
val indexEndpoint = "/index/v2" val indexEndpoint = "/index/v2"
val result = request(indexEndpoint)
result.component1()?.obj()?.getJSONObject("cms")?.let { val index: Index = requestGet(indexEndpoint)
policy = it.get("policy").toString() policy = index.cms.policy
signature = it.get("signature").toString() signature = index.cms.signature
keyPairID = it.get("key_pair_id").toString() keyPairID = index.cms.keyPairId
}
println("policy: $policy") Log.i(TAG, "Policy : $policy")
println("signature: $signature") Log.i(TAG, "Signature : $signature")
println("keyPairID: $keyPairID") Log.i(TAG, "Key Pair ID : $keyPairID")
} }
/** /**
@ -214,18 +250,22 @@ object Crunchyroll {
*/ */
suspend fun account() { suspend fun account() {
val indexEndpoint = "/accounts/v1/me" val indexEndpoint = "/accounts/v1/me"
val result = request(indexEndpoint)
result.component1()?.obj()?.let { val account: Account = try {
accountID = it.get("account_id").toString() requestGet(indexEndpoint)
} catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in account(). This is bad!", ex)
NoneAccount
} }
accountID = account.accountId
} }
/** /**
* General element/media functions: browse, search, objects, season_list * General element/media functions: browse, search, objects, season_list
*/ */
// TODO locale de-DE, categories // TODO categories
/** /**
* Browse the media available on crunchyroll. * Browse the media available on crunchyroll.
* *
@ -241,7 +281,12 @@ object Crunchyroll {
n: Int = 10 n: Int = 10
): BrowseResult { ): BrowseResult {
val browseEndpoint = "/content/v1/browse" val browseEndpoint = "/content/v1/browse"
val noneOptParams = listOf("sort_by" to sortBy.str, "start" to start, "n" to n) val noneOptParams = listOf(
"locale" to locale,
"sort_by" to sortBy.str,
"start" to start,
"n" to n
)
// if a season tag is present add it to the parameters // if a season tag is present add it to the parameters
val parameters = if (seasonTag.isNotEmpty()) { val parameters = if (seasonTag.isNotEmpty()) {
@ -250,10 +295,12 @@ object Crunchyroll {
noneOptParams noneOptParams
} }
val result = request(browseEndpoint, parameters) val browseResult: BrowseResult = try {
val browseResult = result.component1()?.obj()?.let { requestGet(browseEndpoint, parameters)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneBrowseResult Log.e(TAG, "SerializationException in browse().", ex)
NoneBrowseResult
}
// add results to cache TODO improve // add results to cache TODO improve
browsingCache.clear() browsingCache.clear()
@ -269,13 +316,15 @@ object Crunchyroll {
val searchEndpoint = "/content/v1/search" val searchEndpoint = "/content/v1/search"
val parameters = listOf("q" to query, "n" to n, "locale" to locale, "type" to "series") val parameters = listOf("q" to query, "n" to n, "locale" to locale, "type" to "series")
val result = request(searchEndpoint, parameters)
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall, // TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
// to work around this, for now only tv shows are supported // to work around this, for now only tv shows are supported
return result.component1()?.obj()?.let { return try {
json.decodeFromString(it.toString()) requestGet(searchEndpoint, parameters)
} ?: NoneSearchResult }catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in search(), with query = \"$query\".", ex)
NoneSearchResult
}
} }
/** /**
@ -294,11 +343,12 @@ object Crunchyroll {
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
val result = request(episodesEndpoint, parameters) return try {
requestGet(episodesEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in objects().", ex)
} ?: NoneCollection NoneCollection
}
} }
/** /**
@ -309,11 +359,12 @@ object Crunchyroll {
val seasonListEndpoint = "/content/v1/season_list" val seasonListEndpoint = "/content/v1/season_list"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to locale)
val result = request(seasonListEndpoint, parameters) return try {
requestGet(seasonListEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in seasonList().", ex)
} ?: NoneDiscSeasonList NoneDiscSeasonList
}
} }
/** /**
@ -332,11 +383,12 @@ object Crunchyroll {
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
val result = request(seriesEndpoint, parameters) return try {
requestGet(seriesEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in series().", ex)
} ?: NoneSeries NoneSeries
}
} }
/** /**
@ -349,15 +401,16 @@ object Crunchyroll {
"locale" to locale "locale" to locale
) )
val result = request(upNextSeriesEndpoint, parameters) return try {
requestGet(upNextSeriesEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in upNextSeries().", ex)
} ?: NoneUpNextSeriesItem NoneUpNextSeriesItem
}
} }
suspend fun seasons(seriesId: String): Seasons { suspend fun seasons(seriesId: String): Seasons {
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons" val seasonsEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons"
val parameters = listOf( val parameters = listOf(
"series_id" to seriesId, "series_id" to seriesId,
"locale" to locale, "locale" to locale,
@ -366,11 +419,12 @@ object Crunchyroll {
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
val result = request(episodesEndpoint, parameters) return try {
requestGet(seasonsEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in seasons().", ex)
} ?: NoneSeasons NoneSeasons
}
} }
suspend fun episodes(seasonId: String): Episodes { suspend fun episodes(seasonId: String): Episodes {
@ -383,19 +437,21 @@ object Crunchyroll {
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
) )
val result = request(episodesEndpoint, parameters) return try {
requestGet(episodesEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in episodes().", ex)
} ?: NoneEpisodes NoneEpisodes
}
} }
suspend fun playback(url: String): Playback { suspend fun playback(url: String): Playback {
val result = request("", url = url) return try {
requestGet("", url = url)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in playback(), with url = $url.", ex)
} ?: NonePlayback NonePlayback
}
} }
/** /**
@ -412,10 +468,13 @@ object Crunchyroll {
val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId" val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to locale)
val result = request(watchlistSeriesEndpoint, parameters) return try {
// if needed implement parsing (requestGet(watchlistSeriesEndpoint, parameters) as JsonObject)
.containsKey(seriesId)
return result.component1()?.obj()?.has(seriesId) ?: false }catch (ex: SerializationException) {
Log.e(TAG, "SerializationException in isWatchlist() with seriesId = $seriesId", ex)
false
}
} }
/** /**
@ -431,7 +490,7 @@ object Crunchyroll {
put("content_id", seriesId) put("content_id", seriesId)
} }
requestPost(watchlistPostEndpoint, parameters, json.toString()) requestPost(watchlistPostEndpoint, parameters, json)
} }
/** /**
@ -458,11 +517,12 @@ object Crunchyroll {
val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}" val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to locale)
val result = request(playheadsEndpoint, parameters) return try {
requestGet(playheadsEndpoint, parameters)
return result.component1()?.obj()?.let { }catch (ex: SerializationException) {
json.decodeFromString(it.toString()) Log.e(TAG, "SerializationException in upNextSeries().", ex)
} ?: emptyMap() emptyMap()
}
} }
suspend fun postPlayheads(episodeId: String, playhead: Int) { suspend fun postPlayheads(episodeId: String, playhead: Int) {
@ -474,7 +534,7 @@ object Crunchyroll {
put("playhead", playhead) put("playhead", playhead)
} }
requestPost(playheadsEndpoint, parameters, json.toString()) requestPost(playheadsEndpoint, parameters, json)
} }
/** /**
@ -491,10 +551,12 @@ object Crunchyroll {
val watchlistEndpoint = "/content/v1/$accountID/watchlist" val watchlistEndpoint = "/content/v1/$accountID/watchlist"
val parameters = listOf("locale" to locale, "n" to n) val parameters = listOf("locale" to locale, "n" to n)
val result = request(watchlistEndpoint, parameters) val list: ContinueWatchingList = try {
val list: ContinueWatchingList = result.component1()?.obj()?.let { requestGet(watchlistEndpoint, parameters)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneContinueWatchingList Log.e(TAG, "SerializationException in watchlist().", ex)
NoneContinueWatchingList
}
val objects = list.items.map{ it.panel.episodeMetadata.seriesId } val objects = list.items.map{ it.panel.episodeMetadata.seriesId }
return objects(objects) return objects(objects)
@ -510,10 +572,12 @@ object Crunchyroll {
val watchlistEndpoint = "/content/v1/$accountID/up_next_account" val watchlistEndpoint = "/content/v1/$accountID/up_next_account"
val parameters = listOf("locale" to locale, "n" to n) val parameters = listOf("locale" to locale, "n" to n)
val result = request(watchlistEndpoint, parameters) return try {
return result.component1()?.obj()?.let { requestGet(watchlistEndpoint, parameters)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneContinueWatchingList Log.e(TAG, "SerializationException in upNextAccount().", ex)
NoneContinueWatchingList
}
} }
/** /**
@ -523,10 +587,12 @@ object Crunchyroll {
suspend fun profile(): Profile { suspend fun profile(): Profile {
val profileEndpoint = "/accounts/v1/me/profile" val profileEndpoint = "/accounts/v1/me/profile"
val result = request(profileEndpoint) return try {
return result.component1()?.obj()?.let { requestGet(profileEndpoint)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneProfile Log.e(TAG, "SerializationException in profile().", ex)
NoneProfile
}
} }
suspend fun postPrefSubLanguage(languageTag: String) { suspend fun postPrefSubLanguage(languageTag: String) {
@ -535,7 +601,7 @@ object Crunchyroll {
put("preferred_content_subtitle_language", languageTag) put("preferred_content_subtitle_language", languageTag)
} }
requestPatch(profileEndpoint, requestBody = json.toString()) requestPatch(profileEndpoint, bodyObject = json)
} }
} }

View File

@ -28,6 +28,33 @@ enum class SortBy(val str: String) {
POPULARITY("popularity") POPULARITY("popularity")
} }
/**
* index, account. This must pe present for the app to work!
*/
@Serializable
data class Index(
@SerialName("cms") val cms: CMS,
@SerialName("service_available") val serviceAvailable: Boolean,
)
@Serializable
data class CMS(
@SerialName("bucket") val bucket: String,
@SerialName("policy") val policy: String,
@SerialName("signature") val signature: String,
@SerialName("key_pair_id") val keyPairId: String,
@SerialName("expires") val expires: String,
)
@Serializable
data class Account(
@SerialName("account_id") val accountId: String,
@SerialName("external_id") val externalId: String,
@SerialName("email_verified") val emailVerified: Boolean,
@SerialName("created") val created: String,
)
val NoneAccount = Account("", "", false, "")
/** /**
* search, browse, DiscSeasonList, Watchlist, ContinueWatchingList data types all use Collection * search, browse, DiscSeasonList, Watchlist, ContinueWatchingList data types all use Collection
*/ */
@ -47,10 +74,10 @@ typealias ContinueWatchingList = Collection<ContinueWatchingItem>
@Serializable @Serializable
data class UpNextSeriesItem( data class UpNextSeriesItem(
val playhead: Int, @SerialName("playhead") val playhead: Int,
val fully_watched: Boolean, @SerialName("fully_watched") val fullyWatched: Boolean,
val never_watched: Boolean, @SerialName("never_watched") val neverWatched: Boolean,
val panel: EpisodePanel, @SerialName("panel") val panel: EpisodePanel,
) )
/** /**
@ -140,7 +167,7 @@ val NoneBrowseResult = BrowseResult(0, emptyList())
val NoneDiscSeasonList = DiscSeasonList(0, emptyList()) val NoneDiscSeasonList = DiscSeasonList(0, emptyList())
val NoneContinueWatchingList = ContinueWatchingList(0, emptyList()) val NoneContinueWatchingList = ContinueWatchingList(0, emptyList())
val NoneUpNextSeriesItem =UpNextSeriesItem(0, false, false, NoneEpisodePanel) val NoneUpNextSeriesItem = UpNextSeriesItem(0, false, false, NoneEpisodePanel)
/** /**
* Series data type * Series data type