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:
		| @ -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 | ||||
|  | ||||
| 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.call.* | ||||
| 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.client.statement.* | ||||
| import io.ktor.http.* | ||||
| import kotlinx.coroutines.* | ||||
| @ -35,8 +53,7 @@ object Crunchyroll { | ||||
|     } | ||||
|     private const val baseUrl = "https://beta-api.crunchyroll.com" | ||||
|  | ||||
|     private var accessToken = "" | ||||
|     private var tokenType = "" | ||||
|     private lateinit var token: Token | ||||
|     private var tokenValidUntil: Long = 0 | ||||
|  | ||||
|     private var accountID = "" | ||||
| @ -45,10 +62,6 @@ object Crunchyroll { | ||||
|     private var signature = "" | ||||
|     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>() | ||||
|  | ||||
|     /** | ||||
| @ -61,39 +74,24 @@ object Crunchyroll { | ||||
|      */ | ||||
|     fun login(username: String, password: String): Boolean = runBlocking { | ||||
|         val tokenEndpoint = "/auth/v1/token" | ||||
|         val formData = listOf( | ||||
|             "username" to username, | ||||
|             "password" to password, | ||||
|             "grant_type" to "password", | ||||
|             "scope" to "offline_access" | ||||
|         ) | ||||
|         val formData = Parameters.build { | ||||
|             append("username", username) | ||||
|             append("password", password) | ||||
|             append("grant_type", "password") | ||||
|             append("scope", "offline_access") | ||||
|         } | ||||
|  | ||||
|         var success: Boolean // is false | ||||
|         var success = false// is false | ||||
|         withContext(Dispatchers.IO) { | ||||
|             val (request, response, result) = Fuel.post("$baseUrl$tokenEndpoint", parameters = formData) | ||||
|                 .header("Content-Type", "application/x-www-form-urlencoded") | ||||
|                 .appendHeader( | ||||
|                     "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) | ||||
|             // TODO handle exceptions | ||||
|             val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) { | ||||
|                 header("Authorization", "Basic ") | ||||
|             } | ||||
|             token = response.receive() | ||||
|             tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000) | ||||
|  | ||||
| //            println("request: $request") | ||||
| //            println("response: $response") | ||||
| //            println("response: $result") | ||||
|  | ||||
|             Log.i(TAG, "login complete with code ${response.statusCode}") | ||||
|             success = (response.statusCode == 200) | ||||
|             Log.i(TAG, "login complete with code ${response.status}") | ||||
|             success = (response.status == HttpStatusCode.OK) | ||||
|         } | ||||
|  | ||||
|         return@runBlocking success | ||||
| @ -110,22 +108,22 @@ object Crunchyroll { | ||||
|     private suspend inline fun <reified T> request( | ||||
|         url: String, | ||||
|         httpMethod: HttpMethod, | ||||
|         params: Parameters = listOf(), | ||||
|         bodyA: Any = Any() | ||||
|         params: List<Pair<String, Any?>> = listOf(), | ||||
|         bodyObject: 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") | ||||
|                 header("Authorization", "${token.tokenType} ${token.accessToken}") | ||||
|                 params.forEach { | ||||
|                     parameter(it.first, it.second) | ||||
|                 } | ||||
|  | ||||
|                 // for json body set content type | ||||
|                 if (bodyA is JsonObject) { | ||||
|                 // for json set body and content type | ||||
|                 if (bodyObject is JsonObject) { | ||||
|                     body = bodyObject | ||||
|                     contentType(ContentType.Application.Json) | ||||
|                 } | ||||
|             } | ||||
| @ -136,88 +134,45 @@ object Crunchyroll { | ||||
|  | ||||
|     private suspend inline fun <reified T> requestGet( | ||||
|         endpoint: String, | ||||
|         params: Parameters = listOf(), | ||||
|         params: List<Pair<String, Any?>> = listOf(), | ||||
|         url: String = "" | ||||
|     ): T = coroutineScope { | ||||
|     ): T { | ||||
|         val path = url.ifEmpty { "$baseUrl$endpoint" } | ||||
|         if (System.currentTimeMillis() > tokenValidUntil) refreshToken() | ||||
|  | ||||
|         return@coroutineScope (Dispatchers.IO) { | ||||
|             client.request(path) { | ||||
|                 method = HttpMethod.Get | ||||
|                 header("Authorization", "$tokenType $accessToken") | ||||
|                 params.forEach { | ||||
|                     parameter(it.first, it.second) | ||||
|                 } | ||||
|             } as T | ||||
|         } | ||||
|         return request(path, HttpMethod.Get, params) | ||||
|     } | ||||
|  | ||||
|     private suspend fun requestPost( | ||||
|         endpoint: String, | ||||
|         params: Parameters = listOf(), | ||||
|         params: List<Pair<String, Any?>> = listOf(), | ||||
|         bodyObject: JsonObject | ||||
|     ) = coroutineScope { | ||||
|     ) { | ||||
|         val path = "$baseUrl$endpoint" | ||||
|         if (System.currentTimeMillis() > tokenValidUntil) refreshToken() | ||||
|  | ||||
|         withContext(Dispatchers.IO) { | ||||
|             val response: HttpResponse = client.request(path) { | ||||
|                 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") | ||||
|         } | ||||
|         val response: HttpResponse = request(path, HttpMethod.Post, params, bodyObject) | ||||
|         Log.i(TAG, "Response: $response") | ||||
|     } | ||||
|  | ||||
|     private suspend fun requestPatch( | ||||
|         endpoint: String, | ||||
|         params: Parameters = listOf(), | ||||
|         params: List<Pair<String, Any?>> = listOf(), | ||||
|         bodyObject: JsonObject | ||||
|     ) = coroutineScope { | ||||
|     ) { | ||||
|         val path = "$baseUrl$endpoint" | ||||
|         if (System.currentTimeMillis() > tokenValidUntil) refreshToken() | ||||
|  | ||||
|         withContext(Dispatchers.IO) { | ||||
|             val response: HttpResponse = client.request(path) { | ||||
|                 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") | ||||
|         } | ||||
|         val response: HttpResponse = request(path, HttpMethod.Patch, params, bodyObject) | ||||
|         Log.i(TAG, "Response: $response") | ||||
|     } | ||||
|  | ||||
|     private suspend fun requestDelete( | ||||
|         endpoint: String, | ||||
|         params: Parameters = listOf(), | ||||
|         params: List<Pair<String, Any?>> = listOf(), | ||||
|         url: String = "" | ||||
|     ) = coroutineScope { | ||||
|         val path = url.ifEmpty { "$baseUrl$endpoint" } | ||||
|         if (System.currentTimeMillis() > tokenValidUntil) refreshToken() | ||||
|  | ||||
|         withContext(Dispatchers.IO) { | ||||
|             val response: HttpResponse = client.request(path) { | ||||
|                 method = HttpMethod.Delete | ||||
|                 header("Authorization", "$tokenType $accessToken") | ||||
|                 params.forEach { | ||||
|                     parameter(it.first, it.second) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             Log.i(TAG, "Response : $response") | ||||
|         } | ||||
|         val response: HttpResponse = request(path, HttpMethod.Delete, params) | ||||
|         Log.i(TAG, "Response: $response") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -282,7 +237,7 @@ object Crunchyroll { | ||||
|     ): BrowseResult { | ||||
|         val browseEndpoint = "/content/v1/browse" | ||||
|         val noneOptParams = listOf( | ||||
|             "locale" to locale, | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "sort_by" to sortBy.str, | ||||
|             "start" to start, | ||||
|             "n" to n | ||||
| @ -314,7 +269,12 @@ object Crunchyroll { | ||||
|      */ | ||||
|     suspend fun search(query: String, n: Int = 10): SearchResult { | ||||
|         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, | ||||
|         // 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> { | ||||
|         val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}" | ||||
|         val parameters = listOf( | ||||
|             "locale" to locale, | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "Signature" to signature, | ||||
|             "Policy" to policy, | ||||
|             "Key-Pair-Id" to keyPairID | ||||
| @ -357,7 +317,7 @@ object Crunchyroll { | ||||
|     @Suppress("unused") | ||||
|     suspend fun seasonList(): DiscSeasonList { | ||||
|         val seasonListEndpoint = "/content/v1/season_list" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) | ||||
|  | ||||
|         return try { | ||||
|             requestGet(seasonListEndpoint, parameters) | ||||
| @ -375,9 +335,9 @@ object Crunchyroll { | ||||
|      * series id == crunchyroll id? | ||||
|      */ | ||||
|     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( | ||||
|             "locale" to locale, | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "Signature" to signature, | ||||
|             "Policy" to policy, | ||||
|             "Key-Pair-Id" to keyPairID | ||||
| @ -398,7 +358,7 @@ object Crunchyroll { | ||||
|         val upNextSeriesEndpoint = "/content/v1/up_next_series" | ||||
|         val parameters = listOf( | ||||
|             "series_id" to seriesId, | ||||
|             "locale" to locale | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag() | ||||
|         ) | ||||
|  | ||||
|         return try { | ||||
| @ -410,10 +370,10 @@ object Crunchyroll { | ||||
|     } | ||||
|  | ||||
|     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( | ||||
|             "series_id" to seriesId, | ||||
|             "locale" to locale, | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "Signature" to signature, | ||||
|             "Policy" to policy, | ||||
|             "Key-Pair-Id" to keyPairID | ||||
| @ -428,10 +388,10 @@ object Crunchyroll { | ||||
|     } | ||||
|  | ||||
|     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( | ||||
|             "season_id" to seasonId, | ||||
|             "locale" to locale, | ||||
|             "locale" to Preferences.preferredLocale.toLanguageTag(), | ||||
|             "Signature" to signature, | ||||
|             "Policy" to policy, | ||||
|             "Key-Pair-Id" to keyPairID | ||||
| @ -466,7 +426,7 @@ object Crunchyroll { | ||||
|      */ | ||||
|     suspend fun isWatchlist(seriesId: String): Boolean { | ||||
|         val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) | ||||
|  | ||||
|         return try { | ||||
|             (requestGet(watchlistSeriesEndpoint, parameters) as JsonObject) | ||||
| @ -484,7 +444,7 @@ object Crunchyroll { | ||||
|      */ | ||||
|     suspend fun postWatchlist(seriesId: String) { | ||||
|         val watchlistPostEndpoint = "/content/v1/watchlist/$accountID" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) | ||||
|  | ||||
|         val json = buildJsonObject { | ||||
|             put("content_id", seriesId) | ||||
| @ -500,7 +460,7 @@ object Crunchyroll { | ||||
|      */ | ||||
|     suspend fun deleteWatchlist(seriesId: String) { | ||||
|         val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) | ||||
|  | ||||
|         requestDelete(watchlistDeleteEndpoint, parameters) | ||||
|     } | ||||
| @ -515,7 +475,7 @@ object Crunchyroll { | ||||
|      */ | ||||
|     suspend fun playheads(episodeIDs: List<String>): PlayheadsMap { | ||||
|         val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) | ||||
|  | ||||
|         return try { | ||||
|             requestGet(playheadsEndpoint, parameters) | ||||
| @ -527,7 +487,7 @@ object Crunchyroll { | ||||
|  | ||||
|     suspend fun postPlayheads(episodeId: String, playhead: Int) { | ||||
|         val playheadsEndpoint = "/content/v1/playheads/$accountID" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|         val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) | ||||
|  | ||||
|         val json = buildJsonObject { | ||||
|             put("content_id", episodeId) | ||||
| @ -549,7 +509,10 @@ object Crunchyroll { | ||||
|      */ | ||||
|     suspend fun watchlist(n: Int = 20): 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 { | ||||
|             requestGet(watchlistEndpoint, parameters) | ||||
| @ -570,7 +533,10 @@ object Crunchyroll { | ||||
|      */ | ||||
|     suspend fun upNextAccount(n: Int = 20): ContinueWatchingList { | ||||
|         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 { | ||||
|             requestGet(watchlistEndpoint, parameters) | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| 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 | ||||
| data class Index( | ||||
|     @SerialName("cms") val cms: CMS, | ||||
|  | ||||
| @ -10,7 +10,7 @@ object Preferences { | ||||
|  | ||||
|     var preferSecondary = false | ||||
|         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 | ||||
|     var autoplay = true | ||||
|         internal set | ||||
| @ -35,6 +35,15 @@ object Preferences { | ||||
|         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) { | ||||
|         with(getSharedPref(context).edit()) { | ||||
|             putBoolean(context.getString(R.string.save_key_autoplay), autoplay) | ||||
| @ -71,6 +80,11 @@ object Preferences { | ||||
|         preferSecondary = sharedPref.getBoolean( | ||||
|             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( | ||||
|             context.getString(R.string.save_key_autoplay), true | ||||
|         ) | ||||
|  | ||||
| @ -180,18 +180,21 @@ class AccountFragment : Fragment() { | ||||
|         MaterialAlertDialogBuilder(requireContext()) | ||||
|             .setTitle(R.string.settings_content_language) | ||||
|             .setSingleChoiceItems(items, initialSelection){ dialog, which -> | ||||
|                 updatePrefContentLanguage(supportedLocals[which].toLanguageTag()) | ||||
|                 updatePrefContentLanguage(supportedLocals[which]) | ||||
|                 dialog.dismiss() | ||||
|             } | ||||
|             .show() | ||||
|     } | ||||
|  | ||||
|     @kotlinx.coroutines.ExperimentalCoroutinesApi | ||||
|     private fun updatePrefContentLanguage(languageTag: String) { | ||||
|     private fun updatePrefContentLanguage(preferredLocale: Locale) { | ||||
|         lifecycleScope.launch { | ||||
|             Crunchyroll.postPrefSubLanguage(languageTag) | ||||
|             Crunchyroll.postPrefSubLanguage(preferredLocale.toLanguageTag()) | ||||
|  | ||||
|         }.invokeOnCompletion { | ||||
|             // update the local preferred content language | ||||
|             Preferences.savePreferredLocal(requireContext(), preferredLocale) | ||||
|  | ||||
|             // update profile since the language selection might have changed | ||||
|             profile = lifecycleScope.async { Crunchyroll.profile() } | ||||
|             profile.invokeOnCompletion { | ||||
|  | ||||
| @ -62,7 +62,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic | ||||
|         println(upNextSeries) | ||||
|  | ||||
|         // 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) | ||||
|         listOf( | ||||
|  | ||||
| @ -83,7 +83,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) | ||||
|     var currentPlayback = NonePlayback | ||||
|  | ||||
|     // current playback settings | ||||
|     var currentLanguage: Locale = Preferences.preferredLocal | ||||
|     var currentLanguage: Locale = Preferences.preferredLocale | ||||
|         internal set | ||||
|  | ||||
|     init { | ||||
|  | ||||
| @ -22,15 +22,17 @@ | ||||
|  | ||||
| package org.mosad.teapod.util.tmdb | ||||
|  | ||||
| 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 kotlinx.coroutines.* | ||||
| import kotlinx.serialization.ExperimentalSerializationApi | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import android.util.Log | ||||
| import io.ktor.client.* | ||||
| import io.ktor.client.call.* | ||||
| import io.ktor.client.features.json.* | ||||
| import io.ktor.client.features.json.serializer.* | ||||
| import io.ktor.client.request.* | ||||
| import io.ktor.client.statement.* | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.coroutineScope | ||||
| import kotlinx.coroutines.invoke | ||||
| import kotlinx.serialization.SerializationException | ||||
| import kotlinx.serialization.json.Json | ||||
| import org.mosad.teapod.util.concatenate | ||||
|  | ||||
| @ -41,8 +43,14 @@ import org.mosad.teapod.util.concatenate | ||||
|  * | ||||
|  */ | ||||
| class TMDBApiController { | ||||
|     private val classTag = javaClass.name | ||||
|  | ||||
|     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 apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2" | ||||
| @ -52,19 +60,22 @@ class TMDBApiController { | ||||
|         const val imageUrl = "https://image.tmdb.org/t/p/w500" | ||||
|     } | ||||
|  | ||||
|     private suspend fun request( | ||||
|     private suspend inline fun <reified T> request( | ||||
|         endpoint: String, | ||||
|         parameters: Parameters = emptyList() | ||||
|     ): Result<FuelJson, FuelError> = coroutineScope { | ||||
|         parameters: List<Pair<String, Any?>> = emptyList() | ||||
|     ): T = coroutineScope { | ||||
|         val path = "$apiUrl$endpoint" | ||||
|         val params = concatenate(listOf("api_key" to apiKey, "language" to language), parameters) | ||||
|  | ||||
|         // TODO handle FileNotFoundException | ||||
|         return@coroutineScope (Dispatchers.IO) { | ||||
|             val (_, _, result) = Fuel.get(path, params) | ||||
|                 .responseJson() | ||||
|             val response: HttpResponse = client.get(path) { | ||||
|                 params.forEach { | ||||
|                     parameter(it.first, it.second) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             result | ||||
|             response.receive<T>() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -78,10 +89,12 @@ class TMDBApiController { | ||||
|         val searchEndpoint = "/search/multi" | ||||
|         val parameters = listOf("query" to query, "include_adult" to false) | ||||
|  | ||||
|         val result = request(searchEndpoint, parameters) | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneTMDBSearchMovie | ||||
|         return try { | ||||
|             request(searchEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(classTag, "SerializationException in searchMovie(), with query = $query.", ex) | ||||
|             NoneTMDBSearchMovie | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -94,10 +107,12 @@ class TMDBApiController { | ||||
|         val searchEndpoint = "/search/tv" | ||||
|         val parameters = listOf("query" to query, "include_adult" to false) | ||||
|  | ||||
|         val result = request(searchEndpoint, parameters) | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneTMDBSearchTVShow | ||||
|         return try { | ||||
|             request(searchEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(classTag, "SerializationException in searchTVShow(), with query = $query.", ex) | ||||
|             NoneTMDBSearchTVShow | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -109,10 +124,12 @@ class TMDBApiController { | ||||
|         val movieEndpoint = "/movie/$movieId" | ||||
|  | ||||
|         // TODO is FileNotFoundException handling needed? | ||||
|         val result = request(movieEndpoint) | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneTMDBMovie | ||||
|         return try { | ||||
|             request(movieEndpoint) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(classTag, "SerializationException in getMovieDetails(), with movieId = $movieId.", ex) | ||||
|             NoneTMDBMovie | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -124,10 +141,12 @@ class TMDBApiController { | ||||
|         val tvShowEndpoint = "/tv/$tvId" | ||||
|  | ||||
|         // TODO is FileNotFoundException handling needed? | ||||
|         val result = request(tvShowEndpoint) | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneTMDBTVShow | ||||
|         return try { | ||||
|             request(tvShowEndpoint) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(classTag, "SerializationException in getTVShowDetails(), with tvId = $tvId.", ex) | ||||
|             NoneTMDBTVShow | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Suppress("unused") | ||||
| @ -141,10 +160,12 @@ class TMDBApiController { | ||||
|         val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber" | ||||
|  | ||||
|         // TODO is FileNotFoundException handling needed? | ||||
|         val result = request(tvShowSeasonEndpoint) | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneTMDBTVSeason | ||||
|         return try { | ||||
|             request(tvShowSeasonEndpoint) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(classTag, "SerializationException in getTVSeasonDetails(), with tvId = $tvId, seasonNumber = $seasonNumber.", ex) | ||||
|             NoneTMDBTVSeason | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @ -110,8 +110,8 @@ data class TMDBTVShow( | ||||
|  | ||||
| // use null for nullable types, the gui needs to handle/implement a fallback for null values | ||||
| val NoneTMDB = TMDBBase(0, "", "", null, null) | ||||
| val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "", null, "") | ||||
| val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "", "", "") | ||||
| val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "1970-01-01", null, "") | ||||
| val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "1970-01-01", "1970-01-01", "") | ||||
|  | ||||
| @Serializable | ||||
| data class TMDBTVSeason( | ||||
|  | ||||
| @ -132,6 +132,7 @@ | ||||
|     <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_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_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string> | ||||
|     <string name="save_key_theme" translatable="false">org.mosad.teapod.theme</string> | ||||
|  | ||||
		Reference in New Issue
	
	Block a user