Use ktor instead of fuel for http requests [Part 1/2]
This commit is contained in:
		| @ -79,6 +79,7 @@ dependencies { | ||||
|     // TODO replace fuel with ktor | ||||
|     implementation "io.ktor:ktor-client-core:$ktor_version" | ||||
|     implementation "io.ktor:ktor-client-android:$ktor_version" | ||||
|     implementation "io.ktor:ktor-client-serialization:$ktor_version" | ||||
|  | ||||
|     testImplementation 'junit:junit:4.13.2' | ||||
|     androidTestImplementation 'androidx.test.ext:junit:1.1.3' | ||||
|  | ||||
| @ -8,12 +8,15 @@ 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.features.json.* | ||||
| import io.ktor.client.features.json.serializer.* | ||||
| import io.ktor.client.request.* | ||||
| import io.ktor.client.statement.* | ||||
| import io.ktor.http.* | ||||
| import kotlinx.coroutines.* | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.SerializationException | ||||
| import kotlinx.serialization.json.Json | ||||
| import kotlinx.serialization.json.JsonObject | ||||
| import kotlinx.serialization.json.buildJsonObject | ||||
| import kotlinx.serialization.json.put | ||||
| import org.mosad.teapod.preferences.EncryptedPreferences | ||||
| @ -25,7 +28,11 @@ private val json = Json { ignoreUnknownKeys = true } | ||||
| object Crunchyroll { | ||||
|     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 var accessToken = "" | ||||
| @ -100,31 +107,56 @@ object Crunchyroll { | ||||
|      * 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, | ||||
|         params: Parameters = listOf(), | ||||
|         url: String = "" | ||||
|     ): Result<FuelJson, FuelError> = coroutineScope { | ||||
|     ): T = coroutineScope { | ||||
|         val path = url.ifEmpty { "$baseUrl$endpoint" } | ||||
|         if (System.currentTimeMillis() > tokenValidUntil) refreshToken() | ||||
|  | ||||
|         return@coroutineScope (Dispatchers.IO) { | ||||
|             val (request, response, result) = Fuel.get(path, params) | ||||
|                 .header("Authorization", "$tokenType $accessToken") | ||||
|                 .responseJson() | ||||
|  | ||||
| //            println("request request: $request") | ||||
| //            println("request response: $response") | ||||
| //            println("request result: $result") | ||||
|  | ||||
|             result | ||||
|             client.request(path) { | ||||
|                 method = HttpMethod.Get | ||||
|                 header("Authorization", "$tokenType $accessToken") | ||||
|                 params.forEach { | ||||
|                     parameter(it.first, it.second) | ||||
|                 } | ||||
|             } as T | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private suspend fun requestPost( | ||||
|         endpoint: String, | ||||
|         params: Parameters = listOf(), | ||||
|         requestBody: String | ||||
|         bodyObject: JsonObject | ||||
|     ) = coroutineScope { | ||||
|         val path = "$baseUrl$endpoint" | ||||
|         if (System.currentTimeMillis() > tokenValidUntil) refreshToken() | ||||
| @ -132,7 +164,7 @@ object Crunchyroll { | ||||
|         withContext(Dispatchers.IO) { | ||||
|             val response: HttpResponse = client.request(path) { | ||||
|                 method = HttpMethod.Post | ||||
|                 body = requestBody | ||||
|                 body = bodyObject | ||||
|                 header("Authorization", "$tokenType $accessToken") | ||||
|                 contentType(ContentType.Application.Json) | ||||
|                 params.forEach { | ||||
| @ -140,14 +172,14 @@ object Crunchyroll { | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             Log.i(TAG, "Response status: ${response.status}") | ||||
|             Log.i(TAG, "Response: $response") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private suspend fun requestPatch( | ||||
|         endpoint: String, | ||||
|         params: Parameters = listOf(), | ||||
|         requestBody: String | ||||
|         bodyObject: JsonObject | ||||
|     ) = coroutineScope { | ||||
|         val path = "$baseUrl$endpoint" | ||||
|         if (System.currentTimeMillis() > tokenValidUntil) refreshToken() | ||||
| @ -155,7 +187,7 @@ object Crunchyroll { | ||||
|         withContext(Dispatchers.IO) { | ||||
|             val response: HttpResponse = client.request(path) { | ||||
|                 method = HttpMethod.Patch | ||||
|                 body = requestBody | ||||
|                 body = bodyObject | ||||
|                 header("Authorization", "$tokenType $accessToken") | ||||
|                 contentType(ContentType.Application.Json) | ||||
|                 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() | ||||
|  | ||||
|         withContext(Dispatchers.IO) { | ||||
|             Fuel.delete(path, params) | ||||
|                 .header("Authorization", "$tokenType $accessToken") | ||||
|                 .response() // without a response, crunchy doesn't accept the request | ||||
|             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") | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -193,17 +231,15 @@ object Crunchyroll { | ||||
|      */ | ||||
|     suspend fun index() { | ||||
|         val indexEndpoint = "/index/v2" | ||||
|         val result = request(indexEndpoint) | ||||
|  | ||||
|         result.component1()?.obj()?.getJSONObject("cms")?.let { | ||||
|             policy = it.get("policy").toString() | ||||
|             signature = it.get("signature").toString() | ||||
|             keyPairID = it.get("key_pair_id").toString() | ||||
|         } | ||||
|         val index: Index = requestGet(indexEndpoint) | ||||
|         policy = index.cms.policy | ||||
|         signature = index.cms.signature | ||||
|         keyPairID = index.cms.keyPairId | ||||
|  | ||||
|         println("policy: $policy") | ||||
|         println("signature: $signature") | ||||
|         println("keyPairID: $keyPairID") | ||||
|         Log.i(TAG, "Policy : $policy") | ||||
|         Log.i(TAG, "Signature : $signature") | ||||
|         Log.i(TAG, "Key Pair ID : $keyPairID") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -214,18 +250,22 @@ object Crunchyroll { | ||||
|      */ | ||||
|     suspend fun account() { | ||||
|         val indexEndpoint = "/accounts/v1/me" | ||||
|         val result = request(indexEndpoint) | ||||
|  | ||||
|         result.component1()?.obj()?.let { | ||||
|             accountID = it.get("account_id").toString() | ||||
|         val account: Account = try { | ||||
|             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 | ||||
|      */ | ||||
|  | ||||
|     // TODO locale de-DE, categories | ||||
|     // TODO categories | ||||
|     /** | ||||
|      * Browse the media available on crunchyroll. | ||||
|      * | ||||
| @ -241,7 +281,12 @@ object Crunchyroll { | ||||
|         n: Int = 10 | ||||
|     ): BrowseResult { | ||||
|         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 | ||||
|         val parameters = if (seasonTag.isNotEmpty()) { | ||||
| @ -250,10 +295,12 @@ object Crunchyroll { | ||||
|             noneOptParams | ||||
|         } | ||||
|  | ||||
|         val result = request(browseEndpoint, parameters) | ||||
|         val browseResult = result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneBrowseResult | ||||
|         val browseResult: BrowseResult = try { | ||||
|             requestGet(browseEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in browse().", ex) | ||||
|             NoneBrowseResult | ||||
|         } | ||||
|  | ||||
|         // add results to cache TODO improve | ||||
|         browsingCache.clear() | ||||
| @ -269,13 +316,15 @@ object Crunchyroll { | ||||
|         val searchEndpoint = "/content/v1/search" | ||||
|         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, | ||||
|         // to work around this, for now only tv shows are supported | ||||
|  | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneSearchResult | ||||
|         return try { | ||||
|             requestGet(searchEndpoint, parameters) | ||||
|         }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 | ||||
|         ) | ||||
|  | ||||
|         val result = request(episodesEndpoint, parameters) | ||||
|  | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneCollection | ||||
|         return try { | ||||
|             requestGet(episodesEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in objects().", ex) | ||||
|             NoneCollection | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -309,11 +359,12 @@ object Crunchyroll { | ||||
|         val seasonListEndpoint = "/content/v1/season_list" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|  | ||||
|         val result = request(seasonListEndpoint, parameters) | ||||
|  | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneDiscSeasonList | ||||
|         return try { | ||||
|             requestGet(seasonListEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in seasonList().", ex) | ||||
|             NoneDiscSeasonList | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -332,11 +383,12 @@ object Crunchyroll { | ||||
|             "Key-Pair-Id" to keyPairID | ||||
|         ) | ||||
|  | ||||
|         val result = request(seriesEndpoint, parameters) | ||||
|  | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneSeries | ||||
|         return try { | ||||
|             requestGet(seriesEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in series().", ex) | ||||
|             NoneSeries | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -349,15 +401,16 @@ object Crunchyroll { | ||||
|             "locale" to locale | ||||
|         ) | ||||
|  | ||||
|         val result = request(upNextSeriesEndpoint, parameters) | ||||
|  | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneUpNextSeriesItem | ||||
|         return try { | ||||
|             requestGet(upNextSeriesEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in upNextSeries().", ex) | ||||
|             NoneUpNextSeriesItem | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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( | ||||
|             "series_id" to seriesId, | ||||
|             "locale" to locale, | ||||
| @ -366,11 +419,12 @@ object Crunchyroll { | ||||
|             "Key-Pair-Id" to keyPairID | ||||
|         ) | ||||
|  | ||||
|         val result = request(episodesEndpoint, parameters) | ||||
|  | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneSeasons | ||||
|         return try { | ||||
|             requestGet(seasonsEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in seasons().", ex) | ||||
|             NoneSeasons | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun episodes(seasonId: String): Episodes { | ||||
| @ -383,19 +437,21 @@ object Crunchyroll { | ||||
|             "Key-Pair-Id" to keyPairID | ||||
|         ) | ||||
|  | ||||
|         val result = request(episodesEndpoint, parameters) | ||||
|  | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneEpisodes | ||||
|         return try { | ||||
|             requestGet(episodesEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in episodes().", ex) | ||||
|             NoneEpisodes | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun playback(url: String): Playback { | ||||
|         val result = request("", url = url) | ||||
|  | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NonePlayback | ||||
|         return try { | ||||
|             requestGet("", url = url) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in playback(), with url = $url.", ex) | ||||
|             NonePlayback | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -412,10 +468,13 @@ object Crunchyroll { | ||||
|         val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId" | ||||
|         val parameters = listOf("locale" to locale) | ||||
|  | ||||
|         val result = request(watchlistSeriesEndpoint, parameters) | ||||
|         // if needed implement parsing | ||||
|  | ||||
|         return result.component1()?.obj()?.has(seriesId) ?: false | ||||
|         return try { | ||||
|             (requestGet(watchlistSeriesEndpoint, parameters) as JsonObject) | ||||
|                 .containsKey(seriesId) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in isWatchlist() with seriesId = $seriesId", ex) | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -431,7 +490,7 @@ object Crunchyroll { | ||||
|             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 parameters = listOf("locale" to locale) | ||||
|  | ||||
|         val result = request(playheadsEndpoint, parameters) | ||||
|  | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: emptyMap() | ||||
|         return try { | ||||
|             requestGet(playheadsEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in upNextSeries().", ex) | ||||
|             emptyMap() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun postPlayheads(episodeId: String, playhead: Int) { | ||||
| @ -474,7 +534,7 @@ object Crunchyroll { | ||||
|             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 parameters = listOf("locale" to locale, "n" to n) | ||||
|  | ||||
|         val result = request(watchlistEndpoint, parameters) | ||||
|         val list: ContinueWatchingList = result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneContinueWatchingList | ||||
|         val list: ContinueWatchingList = try { | ||||
|             requestGet(watchlistEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in watchlist().", ex) | ||||
|             NoneContinueWatchingList | ||||
|         } | ||||
|  | ||||
|         val objects = list.items.map{ it.panel.episodeMetadata.seriesId } | ||||
|         return objects(objects) | ||||
| @ -510,10 +572,12 @@ object Crunchyroll { | ||||
|         val watchlistEndpoint = "/content/v1/$accountID/up_next_account" | ||||
|         val parameters = listOf("locale" to locale, "n" to n) | ||||
|  | ||||
|         val result = request(watchlistEndpoint, parameters) | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneContinueWatchingList | ||||
|         return try { | ||||
|             requestGet(watchlistEndpoint, parameters) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in upNextAccount().", ex) | ||||
|             NoneContinueWatchingList | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @ -523,10 +587,12 @@ object Crunchyroll { | ||||
|     suspend fun profile(): Profile { | ||||
|         val profileEndpoint = "/accounts/v1/me/profile" | ||||
|  | ||||
|         val result = request(profileEndpoint) | ||||
|         return result.component1()?.obj()?.let { | ||||
|             json.decodeFromString(it.toString()) | ||||
|         } ?: NoneProfile | ||||
|         return try { | ||||
|             requestGet(profileEndpoint) | ||||
|         }catch (ex: SerializationException) { | ||||
|             Log.e(TAG, "SerializationException in profile().", ex) | ||||
|             NoneProfile | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun postPrefSubLanguage(languageTag: String) { | ||||
| @ -535,7 +601,7 @@ object Crunchyroll { | ||||
|             put("preferred_content_subtitle_language", languageTag) | ||||
|         } | ||||
|  | ||||
|         requestPatch(profileEndpoint, requestBody = json.toString()) | ||||
|         requestPatch(profileEndpoint, bodyObject = json) | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @ -28,6 +28,33 @@ enum class SortBy(val str: String) { | ||||
|     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 | ||||
|  */ | ||||
| @ -47,10 +74,10 @@ typealias ContinueWatchingList = Collection<ContinueWatchingItem> | ||||
|  | ||||
| @Serializable | ||||
| data class UpNextSeriesItem( | ||||
|     val playhead: Int, | ||||
|     val fully_watched: Boolean, | ||||
|     val never_watched: Boolean, | ||||
|     val panel: EpisodePanel, | ||||
|     @SerialName("playhead") val playhead: Int, | ||||
|     @SerialName("fully_watched") val fullyWatched: Boolean, | ||||
|     @SerialName("never_watched") val neverWatched: Boolean, | ||||
|     @SerialName("panel") val panel: EpisodePanel, | ||||
| ) | ||||
|  | ||||
| /** | ||||
| @ -140,7 +167,7 @@ val NoneBrowseResult = BrowseResult(0, emptyList()) | ||||
| val NoneDiscSeasonList = DiscSeasonList(0, emptyList()) | ||||
| val NoneContinueWatchingList = ContinueWatchingList(0, emptyList()) | ||||
|  | ||||
| val NoneUpNextSeriesItem =UpNextSeriesItem(0, false, false, NoneEpisodePanel) | ||||
| val NoneUpNextSeriesItem = UpNextSeriesItem(0, false, false, NoneEpisodePanel) | ||||
|  | ||||
| /** | ||||
|  * Series data type | ||||
|  | ||||
		Reference in New Issue
	
	Block a user