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

* update preferred locale in preferences, is is the actual locale implementation
* update token handling for crunchy (country via token)
* update TMDBApiController to use ktor
* add parsable dates to NoneTMDBTVShow and NoneTMDBMovie
This commit is contained in:
Jannik 2022-03-05 20:41:39 +01:00
parent 2016e03e56
commit 75204e522d
Signed by: Seil0
GPG Key ID: E8459F3723C52C24
10 changed files with 205 additions and 172 deletions

View File

@ -14,7 +14,7 @@ android {
minSdkVersion 23 minSdkVersion 23
targetSdkVersion 30 targetSdkVersion 30
versionCode 4200 //00.04.200 versionCode 4200 //00.04.200
versionName "1.0.0-alpha4" versionName "1.0.0-alpha5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "build_time", buildTime() resValue "string", "build_time", buildTime()
@ -72,11 +72,6 @@ dependencies {
implementation 'com.afollestad.material-dialogs:core:3.3.0' implementation 'com.afollestad.material-dialogs:core:3.3.0'
implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0' implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0'
implementation 'com.github.kittinunf.fuel:fuel:2.3.1'
implementation 'com.github.kittinunf.fuel:fuel-android:2.3.1'
implementation 'com.github.kittinunf.fuel:fuel-json:2.3.1'
// 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" implementation "io.ktor:ktor-client-serialization:$ktor_version"

View File

@ -1,16 +1,34 @@
/**
* Teapod
*
* Copyright 2020-2022 <seil0@mosad.xyz>
*
* 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, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*
*/
package org.mosad.teapod.parser.crunchyroll package org.mosad.teapod.parser.crunchyroll
import android.util.Log import android.util.Log
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.FuelError
import com.github.kittinunf.fuel.core.Parameters
import com.github.kittinunf.fuel.json.FuelJson
import com.github.kittinunf.fuel.json.responseJson
import com.github.kittinunf.result.Result
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.features.json.* import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.* import io.ktor.client.features.json.serializer.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -35,8 +53,7 @@ object Crunchyroll {
} }
private const val baseUrl = "https://beta-api.crunchyroll.com" private const val baseUrl = "https://beta-api.crunchyroll.com"
private var accessToken = "" private lateinit var token: Token
private var tokenType = ""
private var tokenValidUntil: Long = 0 private var tokenValidUntil: Long = 0
private var accountID = "" private var accountID = ""
@ -45,10 +62,6 @@ object Crunchyroll {
private var signature = "" private var signature = ""
private var keyPairID = "" private var keyPairID = ""
// TODO temp helper vary
private var locale: String = Preferences.preferredLocal.toLanguageTag()
private var country: String = Preferences.preferredLocal.country
private val browsingCache = arrayListOf<Item>() private val browsingCache = arrayListOf<Item>()
/** /**
@ -61,39 +74,24 @@ object Crunchyroll {
*/ */
fun login(username: String, password: String): Boolean = runBlocking { fun login(username: String, password: String): Boolean = runBlocking {
val tokenEndpoint = "/auth/v1/token" val tokenEndpoint = "/auth/v1/token"
val formData = listOf( val formData = Parameters.build {
"username" to username, append("username", username)
"password" to password, append("password", password)
"grant_type" to "password", append("grant_type", "password")
"scope" to "offline_access" append("scope", "offline_access")
) }
var success: Boolean // is false var success = false// is false
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val (request, response, result) = Fuel.post("$baseUrl$tokenEndpoint", parameters = formData) // TODO handle exceptions
.header("Content-Type", "application/x-www-form-urlencoded") val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) {
.appendHeader( header("Authorization", "Basic ")
"Authorization",
"Basic "
)
.responseJson()
// TODO fix JSONException: No value for
result.component1()?.obj()?.let {
accessToken = it.get("access_token").toString()
tokenType = it.get("token_type").toString()
// token will be invalid 1 sec
val expiresIn = (it.get("expires_in").toString().toLong() - 1)
tokenValidUntil = System.currentTimeMillis() + (expiresIn * 1000)
} }
token = response.receive()
tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000)
// println("request: $request") Log.i(TAG, "login complete with code ${response.status}")
// println("response: $response") success = (response.status == HttpStatusCode.OK)
// println("response: $result")
Log.i(TAG, "login complete with code ${response.statusCode}")
success = (response.statusCode == 200)
} }
return@runBlocking success return@runBlocking success
@ -110,22 +108,22 @@ object Crunchyroll {
private suspend inline fun <reified T> request( private suspend inline fun <reified T> request(
url: String, url: String,
httpMethod: HttpMethod, httpMethod: HttpMethod,
params: Parameters = listOf(), params: List<Pair<String, Any?>> = listOf(),
bodyA: Any = Any() bodyObject: Any = Any()
): T = coroutineScope { ): T = coroutineScope {
if (System.currentTimeMillis() > tokenValidUntil) refreshToken() if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
return@coroutineScope (Dispatchers.IO) { return@coroutineScope (Dispatchers.IO) {
val response: T = client.request(url) { val response: T = client.request(url) {
method = httpMethod method = httpMethod
body = bodyA header("Authorization", "${token.tokenType} ${token.accessToken}")
header("Authorization", "$tokenType $accessToken")
params.forEach { params.forEach {
parameter(it.first, it.second) parameter(it.first, it.second)
} }
// for json body set content type // for json set body and content type
if (bodyA is JsonObject) { if (bodyObject is JsonObject) {
body = bodyObject
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
} }
@ -136,88 +134,45 @@ object Crunchyroll {
private suspend inline fun <reified T> requestGet( private suspend inline fun <reified T> requestGet(
endpoint: String, endpoint: String,
params: Parameters = listOf(), params: List<Pair<String, Any?>> = listOf(),
url: String = "" url: String = ""
): T = coroutineScope { ): T {
val path = url.ifEmpty { "$baseUrl$endpoint" } val path = url.ifEmpty { "$baseUrl$endpoint" }
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
return@coroutineScope (Dispatchers.IO) { return request(path, HttpMethod.Get, params)
client.request(path) {
method = HttpMethod.Get
header("Authorization", "$tokenType $accessToken")
params.forEach {
parameter(it.first, it.second)
}
} as T
}
} }
private suspend fun requestPost( private suspend fun requestPost(
endpoint: String, endpoint: String,
params: Parameters = listOf(), params: List<Pair<String, Any?>> = listOf(),
bodyObject: JsonObject bodyObject: JsonObject
) = coroutineScope { ) {
val path = "$baseUrl$endpoint" val path = "$baseUrl$endpoint"
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
withContext(Dispatchers.IO) { val response: HttpResponse = request(path, HttpMethod.Post, params, bodyObject)
val response: HttpResponse = client.request(path) { Log.i(TAG, "Response: $response")
method = HttpMethod.Post
body = bodyObject
header("Authorization", "$tokenType $accessToken")
contentType(ContentType.Application.Json)
params.forEach {
parameter(it.first, it.second)
}
}
Log.i(TAG, "Response: $response")
}
} }
private suspend fun requestPatch( private suspend fun requestPatch(
endpoint: String, endpoint: String,
params: Parameters = listOf(), params: List<Pair<String, Any?>> = listOf(),
bodyObject: JsonObject bodyObject: JsonObject
) = coroutineScope { ) {
val path = "$baseUrl$endpoint" val path = "$baseUrl$endpoint"
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
withContext(Dispatchers.IO) { val response: HttpResponse = request(path, HttpMethod.Patch, params, bodyObject)
val response: HttpResponse = client.request(path) { Log.i(TAG, "Response: $response")
method = HttpMethod.Patch
body = bodyObject
header("Authorization", "$tokenType $accessToken")
contentType(ContentType.Application.Json)
params.forEach {
parameter(it.first, it.second)
}
}
Log.i(TAG, "Response: $response")
}
} }
private suspend fun requestDelete( private suspend fun requestDelete(
endpoint: String, endpoint: String,
params: Parameters = listOf(), params: List<Pair<String, Any?>> = listOf(),
url: String = "" url: String = ""
) = coroutineScope { ) = coroutineScope {
val path = url.ifEmpty { "$baseUrl$endpoint" } val path = url.ifEmpty { "$baseUrl$endpoint" }
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
withContext(Dispatchers.IO) { val response: HttpResponse = request(path, HttpMethod.Delete, params)
val response: HttpResponse = client.request(path) { Log.i(TAG, "Response: $response")
method = HttpMethod.Delete
header("Authorization", "$tokenType $accessToken")
params.forEach {
parameter(it.first, it.second)
}
}
Log.i(TAG, "Response : $response")
}
} }
/** /**
@ -282,7 +237,7 @@ object Crunchyroll {
): BrowseResult { ): BrowseResult {
val browseEndpoint = "/content/v1/browse" val browseEndpoint = "/content/v1/browse"
val noneOptParams = listOf( val noneOptParams = listOf(
"locale" to locale, "locale" to Preferences.preferredLocale.toLanguageTag(),
"sort_by" to sortBy.str, "sort_by" to sortBy.str,
"start" to start, "start" to start,
"n" to n "n" to n
@ -314,7 +269,12 @@ object Crunchyroll {
*/ */
suspend fun search(query: String, n: Int = 10): SearchResult { suspend fun search(query: String, n: Int = 10): SearchResult {
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(
"locale" to Preferences.preferredLocale.toLanguageTag(),
"q" to query,
"n" to n,
"type" to "series"
)
// 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
@ -337,7 +297,7 @@ object Crunchyroll {
suspend fun objects(objects: List<String>): Collection<Item> { suspend fun objects(objects: List<String>): Collection<Item> {
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}" val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}"
val parameters = listOf( val parameters = listOf(
"locale" to locale, "locale" to Preferences.preferredLocale.toLanguageTag(),
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
@ -357,7 +317,7 @@ object Crunchyroll {
@Suppress("unused") @Suppress("unused")
suspend fun seasonList(): DiscSeasonList { suspend fun seasonList(): DiscSeasonList {
val seasonListEndpoint = "/content/v1/season_list" val seasonListEndpoint = "/content/v1/season_list"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
return try { return try {
requestGet(seasonListEndpoint, parameters) requestGet(seasonListEndpoint, parameters)
@ -375,9 +335,9 @@ object Crunchyroll {
* series id == crunchyroll id? * series id == crunchyroll id?
*/ */
suspend fun series(seriesId: String): Series { suspend fun series(seriesId: String): Series {
val seriesEndpoint = "/cms/v2/$country/M3/crunchyroll/series/$seriesId" val seriesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/series/$seriesId"
val parameters = listOf( val parameters = listOf(
"locale" to locale, "locale" to Preferences.preferredLocale.toLanguageTag(),
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
@ -398,7 +358,7 @@ object Crunchyroll {
val upNextSeriesEndpoint = "/content/v1/up_next_series" val upNextSeriesEndpoint = "/content/v1/up_next_series"
val parameters = listOf( val parameters = listOf(
"series_id" to seriesId, "series_id" to seriesId,
"locale" to locale "locale" to Preferences.preferredLocale.toLanguageTag()
) )
return try { return try {
@ -410,10 +370,10 @@ object Crunchyroll {
} }
suspend fun seasons(seriesId: String): Seasons { suspend fun seasons(seriesId: String): Seasons {
val seasonsEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons" val seasonsEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/seasons"
val parameters = listOf( val parameters = listOf(
"series_id" to seriesId, "series_id" to seriesId,
"locale" to locale, "locale" to Preferences.preferredLocale.toLanguageTag(),
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
@ -428,10 +388,10 @@ object Crunchyroll {
} }
suspend fun episodes(seasonId: String): Episodes { suspend fun episodes(seasonId: String): Episodes {
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/episodes" val episodesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/episodes"
val parameters = listOf( val parameters = listOf(
"season_id" to seasonId, "season_id" to seasonId,
"locale" to locale, "locale" to Preferences.preferredLocale.toLanguageTag(),
"Signature" to signature, "Signature" to signature,
"Policy" to policy, "Policy" to policy,
"Key-Pair-Id" to keyPairID "Key-Pair-Id" to keyPairID
@ -466,7 +426,7 @@ object Crunchyroll {
*/ */
suspend fun isWatchlist(seriesId: String): Boolean { suspend fun isWatchlist(seriesId: String): Boolean {
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 Preferences.preferredLocale.toLanguageTag())
return try { return try {
(requestGet(watchlistSeriesEndpoint, parameters) as JsonObject) (requestGet(watchlistSeriesEndpoint, parameters) as JsonObject)
@ -484,7 +444,7 @@ object Crunchyroll {
*/ */
suspend fun postWatchlist(seriesId: String) { suspend fun postWatchlist(seriesId: String) {
val watchlistPostEndpoint = "/content/v1/watchlist/$accountID" val watchlistPostEndpoint = "/content/v1/watchlist/$accountID"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val json = buildJsonObject { val json = buildJsonObject {
put("content_id", seriesId) put("content_id", seriesId)
@ -500,7 +460,7 @@ object Crunchyroll {
*/ */
suspend fun deleteWatchlist(seriesId: String) { suspend fun deleteWatchlist(seriesId: String) {
val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId" val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
requestDelete(watchlistDeleteEndpoint, parameters) requestDelete(watchlistDeleteEndpoint, parameters)
} }
@ -515,7 +475,7 @@ object Crunchyroll {
*/ */
suspend fun playheads(episodeIDs: List<String>): PlayheadsMap { suspend fun playheads(episodeIDs: List<String>): PlayheadsMap {
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 Preferences.preferredLocale.toLanguageTag())
return try { return try {
requestGet(playheadsEndpoint, parameters) requestGet(playheadsEndpoint, parameters)
@ -527,7 +487,7 @@ object Crunchyroll {
suspend fun postPlayheads(episodeId: String, playhead: Int) { suspend fun postPlayheads(episodeId: String, playhead: Int) {
val playheadsEndpoint = "/content/v1/playheads/$accountID" val playheadsEndpoint = "/content/v1/playheads/$accountID"
val parameters = listOf("locale" to locale) val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
val json = buildJsonObject { val json = buildJsonObject {
put("content_id", episodeId) put("content_id", episodeId)
@ -549,7 +509,10 @@ object Crunchyroll {
*/ */
suspend fun watchlist(n: Int = 20): Watchlist { suspend fun watchlist(n: Int = 20): Watchlist {
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 Preferences.preferredLocale.toLanguageTag(),
"n" to n
)
val list: ContinueWatchingList = try { val list: ContinueWatchingList = try {
requestGet(watchlistEndpoint, parameters) requestGet(watchlistEndpoint, parameters)
@ -570,7 +533,10 @@ object Crunchyroll {
*/ */
suspend fun upNextAccount(n: Int = 20): ContinueWatchingList { suspend fun upNextAccount(n: Int = 20): ContinueWatchingList {
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 Preferences.preferredLocale.toLanguageTag(),
"n" to n
)
return try { return try {
requestGet(watchlistEndpoint, parameters) requestGet(watchlistEndpoint, parameters)

View File

@ -1,3 +1,25 @@
/**
* Teapod
*
* Copyright 2020-2022 <seil0@mosad.xyz>
*
* 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, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*
*/
package org.mosad.teapod.parser.crunchyroll package org.mosad.teapod.parser.crunchyroll
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@ -29,8 +51,19 @@ enum class SortBy(val str: String) {
} }
/** /**
* index, account. This must pe present for the app to work! * token, index, account. This must pe present for the app to work!
*/ */
@Serializable
data class Token(
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("expires_in") val expiresIn: Int,
@SerialName("token_type") val tokenType: String,
@SerialName("scope") val scope: String,
@SerialName("country") val country: String,
@SerialName("account_id") val accountId: String,
)
@Serializable @Serializable
data class Index( data class Index(
@SerialName("cms") val cms: CMS, @SerialName("cms") val cms: CMS,

View File

@ -10,7 +10,7 @@ object Preferences {
var preferSecondary = false var preferSecondary = false
internal set internal set
var preferredLocal = Locale.GERMANY var preferredLocale: Locale = Locale.forLanguageTag("en-US") // TODO this should be saved (potential offline usage) but fetched on start
internal set internal set
var autoplay = true var autoplay = true
internal set internal set
@ -35,6 +35,15 @@ object Preferences {
this.preferSecondary = preferSecondary this.preferSecondary = preferSecondary
} }
fun savePreferredLocal(context: Context, preferredLocale: Locale) {
with(getSharedPref(context).edit()) {
putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag())
apply()
}
this.preferredLocale = preferredLocale
}
fun saveAutoplay(context: Context, autoplay: Boolean) { fun saveAutoplay(context: Context, autoplay: Boolean) {
with(getSharedPref(context).edit()) { with(getSharedPref(context).edit()) {
putBoolean(context.getString(R.string.save_key_autoplay), autoplay) putBoolean(context.getString(R.string.save_key_autoplay), autoplay)
@ -71,6 +80,11 @@ object Preferences {
preferSecondary = sharedPref.getBoolean( preferSecondary = sharedPref.getBoolean(
context.getString(R.string.save_key_prefer_secondary), false context.getString(R.string.save_key_prefer_secondary), false
) )
preferredLocale = Locale.forLanguageTag(
sharedPref.getString(
context.getString(R.string.save_key_preferred_local), "en-US"
) ?: "en-US"
)
autoplay = sharedPref.getBoolean( autoplay = sharedPref.getBoolean(
context.getString(R.string.save_key_autoplay), true context.getString(R.string.save_key_autoplay), true
) )

View File

@ -180,18 +180,21 @@ class AccountFragment : Fragment() {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.settings_content_language) .setTitle(R.string.settings_content_language)
.setSingleChoiceItems(items, initialSelection){ dialog, which -> .setSingleChoiceItems(items, initialSelection){ dialog, which ->
updatePrefContentLanguage(supportedLocals[which].toLanguageTag()) updatePrefContentLanguage(supportedLocals[which])
dialog.dismiss() dialog.dismiss()
} }
.show() .show()
} }
@kotlinx.coroutines.ExperimentalCoroutinesApi @kotlinx.coroutines.ExperimentalCoroutinesApi
private fun updatePrefContentLanguage(languageTag: String) { private fun updatePrefContentLanguage(preferredLocale: Locale) {
lifecycleScope.launch { lifecycleScope.launch {
Crunchyroll.postPrefSubLanguage(languageTag) Crunchyroll.postPrefSubLanguage(preferredLocale.toLanguageTag())
}.invokeOnCompletion { }.invokeOnCompletion {
// update the local preferred content language
Preferences.savePreferredLocal(requireContext(), preferredLocale)
// update profile since the language selection might have changed // update profile since the language selection might have changed
profile = lifecycleScope.async { Crunchyroll.profile() } profile = lifecycleScope.async { Crunchyroll.profile() }
profile.invokeOnCompletion { profile.invokeOnCompletion {

View File

@ -62,7 +62,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
println(upNextSeries) println(upNextSeries)
// load the preferred season (preferred language, language per season, not per stream) // load the preferred season (preferred language, language per season, not per stream)
currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocal) currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocale)
// load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes) // load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes)
listOf( listOf(

View File

@ -83,7 +83,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
var currentPlayback = NonePlayback var currentPlayback = NonePlayback
// current playback settings // current playback settings
var currentLanguage: Locale = Preferences.preferredLocal var currentLanguage: Locale = Preferences.preferredLocale
internal set internal set
init { init {

View File

@ -22,15 +22,17 @@
package org.mosad.teapod.util.tmdb package org.mosad.teapod.util.tmdb
import com.github.kittinunf.fuel.Fuel import android.util.Log
import com.github.kittinunf.fuel.core.FuelError import io.ktor.client.*
import com.github.kittinunf.fuel.core.Parameters import io.ktor.client.call.*
import com.github.kittinunf.fuel.json.FuelJson import io.ktor.client.features.json.*
import com.github.kittinunf.fuel.json.responseJson import io.ktor.client.features.json.serializer.*
import com.github.kittinunf.result.Result import io.ktor.client.request.*
import kotlinx.coroutines.* import io.ktor.client.statement.*
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.decodeFromString import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.invoke
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.mosad.teapod.util.concatenate import org.mosad.teapod.util.concatenate
@ -41,8 +43,14 @@ import org.mosad.teapod.util.concatenate
* *
*/ */
class TMDBApiController { class TMDBApiController {
private val classTag = javaClass.name
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val client = HttpClient {
install(JsonFeature) {
serializer = KotlinxSerializer(json)
}
}
private val apiUrl = "https://api.themoviedb.org/3" private val apiUrl = "https://api.themoviedb.org/3"
private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2" private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2"
@ -52,19 +60,22 @@ class TMDBApiController {
const val imageUrl = "https://image.tmdb.org/t/p/w500" const val imageUrl = "https://image.tmdb.org/t/p/w500"
} }
private suspend fun request( private suspend inline fun <reified T> request(
endpoint: String, endpoint: String,
parameters: Parameters = emptyList() parameters: List<Pair<String, Any?>> = emptyList()
): Result<FuelJson, FuelError> = coroutineScope { ): T = coroutineScope {
val path = "$apiUrl$endpoint" val path = "$apiUrl$endpoint"
val params = concatenate(listOf("api_key" to apiKey, "language" to language), parameters) val params = concatenate(listOf("api_key" to apiKey, "language" to language), parameters)
// TODO handle FileNotFoundException // TODO handle FileNotFoundException
return@coroutineScope (Dispatchers.IO) { return@coroutineScope (Dispatchers.IO) {
val (_, _, result) = Fuel.get(path, params) val response: HttpResponse = client.get(path) {
.responseJson() params.forEach {
parameter(it.first, it.second)
}
}
result response.receive<T>()
} }
} }
@ -78,10 +89,12 @@ class TMDBApiController {
val searchEndpoint = "/search/multi" val searchEndpoint = "/search/multi"
val parameters = listOf("query" to query, "include_adult" to false) val parameters = listOf("query" to query, "include_adult" to false)
val result = request(searchEndpoint, parameters) return try {
return result.component1()?.obj()?.let { request(searchEndpoint, parameters)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneTMDBSearchMovie Log.e(classTag, "SerializationException in searchMovie(), with query = $query.", ex)
NoneTMDBSearchMovie
}
} }
/** /**
@ -94,10 +107,12 @@ class TMDBApiController {
val searchEndpoint = "/search/tv" val searchEndpoint = "/search/tv"
val parameters = listOf("query" to query, "include_adult" to false) val parameters = listOf("query" to query, "include_adult" to false)
val result = request(searchEndpoint, parameters) return try {
return result.component1()?.obj()?.let { request(searchEndpoint, parameters)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneTMDBSearchTVShow Log.e(classTag, "SerializationException in searchTVShow(), with query = $query.", ex)
NoneTMDBSearchTVShow
}
} }
/** /**
@ -109,10 +124,12 @@ class TMDBApiController {
val movieEndpoint = "/movie/$movieId" val movieEndpoint = "/movie/$movieId"
// TODO is FileNotFoundException handling needed? // TODO is FileNotFoundException handling needed?
val result = request(movieEndpoint) return try {
return result.component1()?.obj()?.let { request(movieEndpoint)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneTMDBMovie Log.e(classTag, "SerializationException in getMovieDetails(), with movieId = $movieId.", ex)
NoneTMDBMovie
}
} }
/** /**
@ -124,10 +141,12 @@ class TMDBApiController {
val tvShowEndpoint = "/tv/$tvId" val tvShowEndpoint = "/tv/$tvId"
// TODO is FileNotFoundException handling needed? // TODO is FileNotFoundException handling needed?
val result = request(tvShowEndpoint) return try {
return result.component1()?.obj()?.let { request(tvShowEndpoint)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneTMDBTVShow Log.e(classTag, "SerializationException in getTVShowDetails(), with tvId = $tvId.", ex)
NoneTMDBTVShow
}
} }
@Suppress("unused") @Suppress("unused")
@ -141,10 +160,12 @@ class TMDBApiController {
val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber" val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber"
// TODO is FileNotFoundException handling needed? // TODO is FileNotFoundException handling needed?
val result = request(tvShowSeasonEndpoint) return try {
return result.component1()?.obj()?.let { request(tvShowSeasonEndpoint)
json.decodeFromString(it.toString()) }catch (ex: SerializationException) {
} ?: NoneTMDBTVSeason Log.e(classTag, "SerializationException in getTVSeasonDetails(), with tvId = $tvId, seasonNumber = $seasonNumber.", ex)
NoneTMDBTVSeason
}
} }
} }

View File

@ -110,8 +110,8 @@ data class TMDBTVShow(
// use null for nullable types, the gui needs to handle/implement a fallback for null values // use null for nullable types, the gui needs to handle/implement a fallback for null values
val NoneTMDB = TMDBBase(0, "", "", null, null) val NoneTMDB = TMDBBase(0, "", "", null, null)
val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "", null, "") val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "1970-01-01", null, "")
val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "", "", "") val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "1970-01-01", "1970-01-01", "")
@Serializable @Serializable
data class TMDBTVSeason( data class TMDBTVSeason(

View File

@ -132,6 +132,7 @@
<string name="save_key_user_login" translatable="false">org.mosad.teapod.user_login</string> <string name="save_key_user_login" translatable="false">org.mosad.teapod.user_login</string>
<string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string> <string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string>
<string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string> <string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string>
<string name="save_key_preferred_local" translatable="false">org.mosad.teapod.preferred_local</string>
<string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string> <string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string>
<string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string> <string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string>
<string name="save_key_theme" translatable="false">org.mosad.teapod.theme</string> <string name="save_key_theme" translatable="false">org.mosad.teapod.theme</string>