mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-09 00:17:35 +00:00
Migrate Meta/MetaDetail models from Gson to kotlinx.serialization
Gson is JVM-only and can't live in shared commonMain, which was the real reason Meta/MetaDetail/Video/CastMember/DetailTrailer were stuck in the Android-only app module. Ports the existing JsonElement-tree-based custom parsing (flexible alternate-key extraction, free-form cast/trailer JSON tolerance) to kotlinx.serialization's equivalent JsonObject/JsonElement API, using @JsonNames for simple alternate keys and custom KSerializers for CastMember/DetailTrailer/MetaDetail where the existing Gson deserializers did manual extraction. StremioAddonResourceClient now parses addon catalog/meta responses via kotlinx.serialization (stremioJson) instead of Gson for these types; Gson stays in place for Stream/SubtitleData, which are out of scope here. StremioService.getMetaDetail now returns the raw response body instead of relying on Retrofit's automatic (Gson-based) conversion, since the models it returns no longer carry Gson annotations. Verified on-device: addon catalog list parsing (Home rows), billboard, and meta detail (title/year/runtime/description) all render correctly against real addon responses.
This commit is contained in:
parent
d4c9ae6edd
commit
e5581918cd
6 changed files with 349 additions and 239 deletions
|
|
@ -4,6 +4,7 @@ plugins {
|
|||
alias(libs.plugins.fluxa.kmp.library)
|
||||
alias(libs.plugins.fluxa.android.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
val localProperties = Properties().apply {
|
||||
|
|
@ -43,6 +44,11 @@ kotlin {
|
|||
tvosSimulatorArm64()
|
||||
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
}
|
||||
androidMain {
|
||||
kotlin.srcDir("src/main/java")
|
||||
dependencies {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,32 @@
|
|||
package com.fluxa.app.data.remote
|
||||
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.annotations.JsonAdapter
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.lang.reflect.Type
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonEncoder
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
data class MetaRating(val source: String, val value: Any?)
|
||||
@Serializable
|
||||
data class MetaRating(val source: String, val value: String?)
|
||||
|
||||
@Serializable
|
||||
data class AppExtras(
|
||||
val seasonPosters: List<String?>? = null,
|
||||
val certification: String? = null,
|
||||
|
|
@ -16,85 +34,26 @@ data class AppExtras(
|
|||
val cast: List<CastMember>? = null
|
||||
)
|
||||
|
||||
@JsonAdapter(MetaDetailDeserializer::class)
|
||||
data class MetaDetail(
|
||||
@Serializable
|
||||
data class MetaLink(val name: String, val category: String, val url: String)
|
||||
|
||||
@Serializable
|
||||
data class Video(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val name: String,
|
||||
@JsonAdapter(StringListDeserializer::class) val genres: List<String>?,
|
||||
val poster: String?,
|
||||
val background: String?,
|
||||
val logo: String?,
|
||||
val description: String?,
|
||||
val releaseInfo: String?,
|
||||
val released: String? = null,
|
||||
val runtime: String?,
|
||||
@SerializedName(value = "videos", alternate = ["episodes"]) val videos: List<Video>?,
|
||||
val trailers: List<DetailTrailer>? = null,
|
||||
val imdbRating: String? = null,
|
||||
val ageRating: String? = null,
|
||||
val ratings: List<MetaRating>? = null,
|
||||
val cast: List<CastMember>? = null,
|
||||
@JsonAdapter(StringListDeserializer::class) val director: List<String>? = null,
|
||||
val links: List<MetaLink>? = null,
|
||||
val status: String? = null,
|
||||
val seasonsCount: Int? = null,
|
||||
@JsonAdapter(StringListDeserializer::class) val platforms: List<String>? = null,
|
||||
val awards: String? = null,
|
||||
val originalLanguage: String? = null,
|
||||
val originalName: String? = null,
|
||||
val country: String? = null,
|
||||
@JsonAdapter(StringListDeserializer::class) val productionCompanies: List<String>? = null,
|
||||
@JsonAdapter(StringListDeserializer::class) val networks: List<String>? = null,
|
||||
val collectionName: String? = null,
|
||||
val collectionId: Int? = null,
|
||||
val collectionParts: List<Meta>? = null,
|
||||
val seasonPosters: Map<String, String>? = null,
|
||||
@SerializedName("app_extras") val appExtras: AppExtras? = null
|
||||
@SerialName("name") val name: String? = null,
|
||||
val season: Int? = null,
|
||||
@SerialName("number") val number: Int? = null,
|
||||
@SerialName("released") val released: String? = null,
|
||||
val thumbnail: String? = null,
|
||||
@SerialName("overview") val overview: String? = null,
|
||||
val rating: String? = null,
|
||||
val episodeRuntime: Int? = null
|
||||
)
|
||||
|
||||
class MetaDetailDeserializer : JsonDeserializer<MetaDetail> {
|
||||
override fun deserialize(json: JsonElement, typeOfT: Type, context: com.google.gson.JsonDeserializationContext): MetaDetail {
|
||||
val obj = json.asObjectOrNull() ?: JsonObject()
|
||||
return MetaDetail(
|
||||
id = obj.text("id").orEmpty(),
|
||||
type = obj.text("type").orEmpty(),
|
||||
name = obj.text("name").orEmpty(),
|
||||
genres = obj.stringList("genres"),
|
||||
poster = obj.text("poster"),
|
||||
background = obj.text("background"),
|
||||
logo = obj.text("logo"),
|
||||
description = obj.text("description"),
|
||||
releaseInfo = obj.text("releaseInfo", "year"),
|
||||
released = obj.text("released"),
|
||||
runtime = obj.text("runtime"),
|
||||
videos = obj.videoList("videos", "episodes"),
|
||||
trailers = obj.trailerList(),
|
||||
imdbRating = obj.text("imdbRating", "imdb_rating"),
|
||||
ageRating = obj.text("ageRating", "age_rating"),
|
||||
ratings = obj.ratingList(),
|
||||
cast = obj.castList("cast"),
|
||||
director = obj.stringList("director"),
|
||||
links = obj.linkList(),
|
||||
status = obj.text("status"),
|
||||
seasonsCount = obj.int("seasonsCount", "seasons_count"),
|
||||
platforms = obj.stringList("platforms"),
|
||||
awards = obj.text("awards"),
|
||||
originalLanguage = obj.text("originalLanguage", "original_language"),
|
||||
originalName = obj.text("originalName", "original_name"),
|
||||
country = obj.text("country"),
|
||||
productionCompanies = obj.stringList("productionCompanies", "production_companies"),
|
||||
networks = obj.stringList("networks"),
|
||||
collectionName = obj.text("collectionName", "collection_name"),
|
||||
collectionId = obj.int("collectionId", "collection_id"),
|
||||
collectionParts = null,
|
||||
seasonPosters = obj.stringMap("seasonPosters", "season_posters"),
|
||||
appExtras = obj.appExtras()
|
||||
)
|
||||
}
|
||||
}
|
||||
@Serializable(with = CastMemberSerializer::class)
|
||||
data class CastMember(val name: String, val character: String?, val profilePath: String?)
|
||||
|
||||
@JsonAdapter(DetailTrailerDeserializer::class)
|
||||
@Serializable(with = DetailTrailerSerializer::class)
|
||||
data class DetailTrailer(
|
||||
val id: String,
|
||||
val title: String,
|
||||
|
|
@ -104,57 +63,55 @@ data class DetailTrailer(
|
|||
val source: String
|
||||
)
|
||||
|
||||
class DetailTrailerDeserializer : JsonDeserializer<DetailTrailer> {
|
||||
override fun deserialize(json: JsonElement, typeOfT: Type, context: com.google.gson.JsonDeserializationContext): DetailTrailer {
|
||||
return detailTrailerFromJson(json)
|
||||
}
|
||||
}
|
||||
@Serializable
|
||||
data class MetaDetailResponse(val meta: MetaDetail? = null)
|
||||
|
||||
class StringListDeserializer : JsonDeserializer<List<String>?> {
|
||||
override fun deserialize(json: JsonElement, typeOfT: Type, context: com.google.gson.JsonDeserializationContext): List<String>? {
|
||||
if (json.isJsonNull) return null
|
||||
if (json.isJsonArray) {
|
||||
return json.asJsonArray
|
||||
.mapNotNull { item -> item.safeString()?.trim()?.takeIf { value -> value.isNotBlank() } }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
return json.safeString()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.split(',')
|
||||
?.mapNotNull { it.trim().takeIf(String::isNotBlank) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
@JsonAdapter(CastMemberDeserializer::class)
|
||||
data class CastMember(val name: String, val character: String?, val profilePath: String?)
|
||||
|
||||
class CastMemberDeserializer : JsonDeserializer<CastMember> {
|
||||
override fun deserialize(json: JsonElement, typeOfT: Type, context: com.google.gson.JsonDeserializationContext): CastMember {
|
||||
return castMemberFromJson(json)
|
||||
}
|
||||
}
|
||||
data class MetaLink(val name: String, val category: String, val url: String)
|
||||
data class Video(
|
||||
@Serializable(with = MetaDetailSerializer::class)
|
||||
data class MetaDetail(
|
||||
val id: String,
|
||||
@SerializedName(value = "name", alternate = ["title"]) val name: String?,
|
||||
val season: Int?,
|
||||
@SerializedName(value = "number", alternate = ["episode"]) val number: Int?,
|
||||
@SerializedName(value = "released", alternate = ["firstAired"]) val released: String?,
|
||||
val thumbnail: String?,
|
||||
@SerializedName(value = "overview", alternate = ["description"]) val overview: String? = null,
|
||||
val rating: String? = null,
|
||||
val episodeRuntime: Int? = null
|
||||
val type: String,
|
||||
val name: String,
|
||||
val genres: List<String>?,
|
||||
val poster: String?,
|
||||
val background: String?,
|
||||
val logo: String?,
|
||||
val description: String?,
|
||||
val releaseInfo: String?,
|
||||
val released: String? = null,
|
||||
val runtime: String?,
|
||||
val videos: List<Video>?,
|
||||
val trailers: List<DetailTrailer>? = null,
|
||||
val imdbRating: String? = null,
|
||||
val ageRating: String? = null,
|
||||
val ratings: List<MetaRating>? = null,
|
||||
val cast: List<CastMember>? = null,
|
||||
val director: List<String>? = null,
|
||||
val links: List<MetaLink>? = null,
|
||||
val status: String? = null,
|
||||
val seasonsCount: Int? = null,
|
||||
val platforms: List<String>? = null,
|
||||
val awards: String? = null,
|
||||
val originalLanguage: String? = null,
|
||||
val originalName: String? = null,
|
||||
val country: String? = null,
|
||||
val productionCompanies: List<String>? = null,
|
||||
val networks: List<String>? = null,
|
||||
val collectionName: String? = null,
|
||||
val collectionId: Int? = null,
|
||||
val collectionParts: List<Meta>? = null,
|
||||
val seasonPosters: Map<String, String>? = null,
|
||||
val appExtras: AppExtras? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Meta(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val type: String,
|
||||
val poster: String?,
|
||||
val background: String? = null,
|
||||
val logo: String? = null,
|
||||
val description: String? = null,
|
||||
val name: String,
|
||||
val type: String,
|
||||
val poster: String?,
|
||||
val background: String? = null,
|
||||
val logo: String? = null,
|
||||
val description: String? = null,
|
||||
val imdbRating: String? = null,
|
||||
val ageRating: String? = null,
|
||||
val ratings: List<MetaRating>? = null,
|
||||
|
|
@ -180,40 +137,98 @@ data class Meta(
|
|||
val reason: String? = null,
|
||||
val homeBadge: String? = null,
|
||||
val originalLanguage: String? = null,
|
||||
val originalName: String? = null, // TMDB original_name for CS3 multi-search
|
||||
val originalName: String? = null,
|
||||
val continueWatchingPoster: String? = null,
|
||||
val continueWatchingBackground: String? = null,
|
||||
val focusGifUrl: String? = null,
|
||||
val coverEmoji: String? = null,
|
||||
val hideTitle: Boolean? = null,
|
||||
val focusGlowEnabled: Boolean? = null,
|
||||
@SerializedName(value = "videos", alternate = ["episodes"]) val videos: List<Video>? = null,
|
||||
@SerialName("videos") val videos: List<Video>? = null,
|
||||
val trailers: List<DetailTrailer>? = null,
|
||||
val seasonPosters: Map<String, String>? = null
|
||||
)
|
||||
|
||||
private fun JsonElement?.safeString(): String? {
|
||||
val element = this?.takeIf { !it.isJsonNull } ?: return null
|
||||
return if (element.isJsonPrimitive) element.asJsonPrimitive.asString else null
|
||||
val element = this?.takeUnless { it is JsonNull } ?: return null
|
||||
return (element as? JsonPrimitive)?.takeIf { it.isString || it.contentOrNull != null }?.content
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? {
|
||||
val element = this?.takeIf { !it.isJsonNull } ?: return null
|
||||
return if (element.isJsonObject) element.asJsonObject else null
|
||||
val element = this?.takeUnless { it is JsonNull } ?: return null
|
||||
return element as? JsonObject
|
||||
}
|
||||
|
||||
private fun JsonObject.first(vararg keys: String): JsonElement? =
|
||||
keys.firstNotNullOfOrNull { key -> get(key)?.takeIf { !it.isJsonNull } }
|
||||
keys.firstNotNullOfOrNull { key -> get(key)?.takeUnless { it is JsonNull } }
|
||||
|
||||
private fun JsonObject.text(vararg keys: String): String? =
|
||||
first(*keys)?.safeString()?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun JsonObject.int(vararg keys: String): Int? {
|
||||
val value = first(*keys)?.safeString()?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return value.toIntOrNull() ?: value.toDoubleOrNull()?.toInt()
|
||||
}
|
||||
|
||||
private fun JsonObject.stringList(vararg keys: String): List<String>? {
|
||||
val value = first(*keys) ?: return null
|
||||
return when (value) {
|
||||
is JsonArray -> value
|
||||
.mapNotNull { it.safeString()?.trim()?.takeIf(String::isNotBlank) }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
else -> value.safeString()
|
||||
?.split(',')
|
||||
?.mapNotNull { it.trim().takeIf(String::isNotBlank) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.stringMap(vararg keys: String): Map<String, String>? {
|
||||
val value = first(*keys) ?: return null
|
||||
return when (value) {
|
||||
is JsonObject -> value.entries
|
||||
.mapNotNull { entry -> entry.value.safeString()?.takeIf(String::isNotBlank)?.let { entry.key to it } }
|
||||
.toMap()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
is JsonArray -> value
|
||||
.mapIndexedNotNull { index, item -> item.safeString()?.takeIf(String::isNotBlank)?.let { index.toString() to it } }
|
||||
.toMap()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.objectList(vararg keys: String): List<JsonObject> {
|
||||
val value = first(*keys) ?: return emptyList()
|
||||
return when (value) {
|
||||
is JsonArray -> value.mapNotNull { it.asObjectOrNull() }
|
||||
is JsonObject -> listOf(value)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.videoList(vararg keys: String): List<Video>? =
|
||||
objectList(*keys).mapNotNull { obj ->
|
||||
val id = obj.text("id") ?: return@mapNotNull null
|
||||
Video(
|
||||
id = id,
|
||||
name = obj.text("name", "title"),
|
||||
season = obj.int("season"),
|
||||
number = obj.int("number", "episode"),
|
||||
released = obj.text("released", "firstAired"),
|
||||
thumbnail = obj.text("thumbnail"),
|
||||
overview = obj.text("overview", "description"),
|
||||
rating = obj.text("rating"),
|
||||
episodeRuntime = obj.int("episodeRuntime", "runtime")
|
||||
)
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun detailTrailerFromJson(json: JsonElement): DetailTrailer {
|
||||
val obj = json.asObjectOrNull() ?: JsonObject().apply {
|
||||
addProperty("source", json.safeString())
|
||||
val obj = json.asObjectOrNull() ?: buildJsonObject {
|
||||
put("source", json.safeString())
|
||||
}
|
||||
fun text(vararg keys: String): String? = keys.firstNotNullOfOrNull { key ->
|
||||
obj.get(key)?.safeString()?.trim()?.takeIf { it.isNotBlank() }
|
||||
obj[key]?.safeString()?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
val rawSource = text("source")
|
||||
val youtubeId = text("ytId") ?: rawSource?.takeUnless { it.startsWith("http://") || it.startsWith("https://") }
|
||||
|
|
@ -231,14 +246,14 @@ private fun detailTrailerFromJson(json: JsonElement): DetailTrailer {
|
|||
)
|
||||
}
|
||||
|
||||
private fun castMemberFromJson(json: JsonElement): CastMember {
|
||||
if (json.isJsonPrimitive) return CastMember(json.safeString().orEmpty(), null, null)
|
||||
val obj = json.asObjectOrNull() ?: JsonObject()
|
||||
return CastMember(
|
||||
name = obj.castMemberName(),
|
||||
character = obj.text("character", "role", "as"),
|
||||
profilePath = obj.text("profilePath", "profile_path", "photo", "profile", "image", "img")
|
||||
)
|
||||
private fun JsonObject.trailerList(): List<DetailTrailer>? {
|
||||
val value = first("trailers") ?: return null
|
||||
val items = when (value) {
|
||||
is JsonArray -> value.toList()
|
||||
else -> listOf(value)
|
||||
}
|
||||
return items.mapNotNull { runCatching { detailTrailerFromJson(it) }.getOrNull() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun JsonObject.castMemberName(): String {
|
||||
|
|
@ -275,84 +290,20 @@ private fun JsonElement.castNameValue(): String? {
|
|||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.int(vararg keys: String): Int? {
|
||||
val value = first(*keys)?.safeString()?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return value.toIntOrNull() ?: value.toDoubleOrNull()?.toInt()
|
||||
private fun castMemberFromJson(json: JsonElement): CastMember {
|
||||
if (json is JsonPrimitive && json.isString) return CastMember(json.content, null, null)
|
||||
val obj = json.asObjectOrNull() ?: return CastMember("", null, null)
|
||||
return CastMember(
|
||||
name = obj.castMemberName(),
|
||||
character = obj.text("character", "role", "as"),
|
||||
profilePath = obj.text("profilePath", "profile_path", "photo", "profile", "image", "img")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.stringList(vararg keys: String): List<String>? {
|
||||
val value = first(*keys) ?: return null
|
||||
return when {
|
||||
value.isJsonArray -> value.asJsonArray
|
||||
.mapNotNull { it.safeString()?.trim()?.takeIf(String::isNotBlank) }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
else -> value.safeString()
|
||||
?.split(',')
|
||||
?.mapNotNull { it.trim().takeIf(String::isNotBlank) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.stringMap(vararg keys: String): Map<String, String>? {
|
||||
val value = first(*keys) ?: return null
|
||||
return when {
|
||||
value.isJsonObject -> value.asJsonObject.entrySet()
|
||||
.mapNotNull { entry -> entry.value.safeString()?.takeIf(String::isNotBlank)?.let { entry.key to it } }
|
||||
.toMap()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
value.isJsonArray -> value.asJsonArray
|
||||
.mapIndexedNotNull { index, item -> item.safeString()?.takeIf(String::isNotBlank)?.let { index.toString() to it } }
|
||||
.toMap()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.objectList(vararg keys: String): List<JsonObject> {
|
||||
val value = first(*keys) ?: return emptyList()
|
||||
return when {
|
||||
value.isJsonArray -> value.asJsonArray.mapNotNull { it.asObjectOrNull() }
|
||||
value.isJsonObject -> listOf(value.asJsonObject)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.videoList(vararg keys: String): List<Video>? =
|
||||
objectList(*keys).mapNotNull { obj ->
|
||||
val id = obj.text("id") ?: return@mapNotNull null
|
||||
Video(
|
||||
id = id,
|
||||
name = obj.text("name", "title"),
|
||||
season = obj.int("season"),
|
||||
number = obj.int("number", "episode"),
|
||||
released = obj.text("released", "firstAired"),
|
||||
thumbnail = obj.text("thumbnail"),
|
||||
overview = obj.text("overview", "description"),
|
||||
rating = obj.text("rating"),
|
||||
episodeRuntime = obj.int("episodeRuntime", "runtime")
|
||||
)
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject.trailerList(): List<DetailTrailer>? {
|
||||
val value = first("trailers") ?: return null
|
||||
val items = when {
|
||||
value.isJsonArray -> value.asJsonArray.toList()
|
||||
else -> listOf(value)
|
||||
}
|
||||
return items.mapNotNull { runCatching { detailTrailerFromJson(it) }.getOrNull() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun JsonObject.ratingList(): List<MetaRating>? =
|
||||
objectList("ratings").mapNotNull { obj ->
|
||||
val source = obj.text("source") ?: return@mapNotNull null
|
||||
MetaRating(source = source, value = obj.first("value")?.safeString())
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject.castList(vararg keys: String): List<CastMember>? {
|
||||
val value = first(*keys) ?: return null
|
||||
val items = when {
|
||||
value.isJsonArray -> value.asJsonArray.toList()
|
||||
val items = when (value) {
|
||||
is JsonArray -> value.toList()
|
||||
else -> listOf(value)
|
||||
}
|
||||
return items.mapNotNull { runCatching { castMemberFromJson(it) }.getOrNull() }
|
||||
|
|
@ -368,12 +319,18 @@ private fun JsonObject.linkList(): List<MetaLink>? =
|
|||
MetaLink(name, category, url)
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject.ratingList(): List<MetaRating>? =
|
||||
objectList("ratings").mapNotNull { obj ->
|
||||
val source = obj.text("source") ?: return@mapNotNull null
|
||||
MetaRating(source = source, value = obj.first("value")?.safeString())
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject.appExtras(): AppExtras? {
|
||||
val obj = first("app_extras", "appExtras").asObjectOrNull() ?: return null
|
||||
return AppExtras(
|
||||
seasonPosters = obj.first("seasonPosters")?.let { value ->
|
||||
when {
|
||||
value.isJsonArray -> value.asJsonArray.map { it.safeString() }
|
||||
when (value) {
|
||||
is JsonArray -> value.map { it.safeString() }
|
||||
else -> listOf(value.safeString())
|
||||
}
|
||||
},
|
||||
|
|
@ -382,3 +339,129 @@ private fun JsonObject.appExtras(): AppExtras? {
|
|||
cast = obj.castList("cast")
|
||||
)
|
||||
}
|
||||
|
||||
private fun metaDetailFromJson(json: JsonElement): MetaDetail {
|
||||
val obj = json.asObjectOrNull() ?: JsonObject(emptyMap())
|
||||
return MetaDetail(
|
||||
id = obj.text("id").orEmpty(),
|
||||
type = obj.text("type").orEmpty(),
|
||||
name = obj.text("name").orEmpty(),
|
||||
genres = obj.stringList("genres"),
|
||||
poster = obj.text("poster"),
|
||||
background = obj.text("background"),
|
||||
logo = obj.text("logo"),
|
||||
description = obj.text("description"),
|
||||
releaseInfo = obj.text("releaseInfo", "year"),
|
||||
released = obj.text("released"),
|
||||
runtime = obj.text("runtime"),
|
||||
videos = obj.videoList("videos", "episodes"),
|
||||
trailers = obj.trailerList(),
|
||||
imdbRating = obj.text("imdbRating", "imdb_rating"),
|
||||
ageRating = obj.text("ageRating", "age_rating"),
|
||||
ratings = obj.ratingList(),
|
||||
cast = obj.castList("cast"),
|
||||
director = obj.stringList("director"),
|
||||
links = obj.linkList(),
|
||||
status = obj.text("status"),
|
||||
seasonsCount = obj.int("seasonsCount", "seasons_count"),
|
||||
platforms = obj.stringList("platforms"),
|
||||
awards = obj.text("awards"),
|
||||
originalLanguage = obj.text("originalLanguage", "original_language"),
|
||||
originalName = obj.text("originalName", "original_name"),
|
||||
country = obj.text("country"),
|
||||
productionCompanies = obj.stringList("productionCompanies", "production_companies"),
|
||||
networks = obj.stringList("networks"),
|
||||
collectionName = obj.text("collectionName", "collection_name"),
|
||||
collectionId = obj.int("collectionId", "collection_id"),
|
||||
collectionParts = null,
|
||||
seasonPosters = obj.stringMap("seasonPosters", "season_posters"),
|
||||
appExtras = obj.appExtras()
|
||||
)
|
||||
}
|
||||
|
||||
object CastMemberSerializer : KSerializer<CastMember> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastMember", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CastMember) {
|
||||
val json = encoder as? JsonEncoder ?: error("CastMember can only be serialized to JSON")
|
||||
json.encodeJsonElement(buildJsonObject {
|
||||
put("name", value.name)
|
||||
value.character?.let { put("character", it) }
|
||||
value.profilePath?.let { put("profilePath", it) }
|
||||
})
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): CastMember {
|
||||
val json = decoder as? JsonDecoder ?: error("CastMember can only be deserialized from JSON")
|
||||
return castMemberFromJson(json.decodeJsonElement())
|
||||
}
|
||||
}
|
||||
|
||||
object DetailTrailerSerializer : KSerializer<DetailTrailer> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("DetailTrailer", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: DetailTrailer) {
|
||||
val json = encoder as? JsonEncoder ?: error("DetailTrailer can only be serialized to JSON")
|
||||
json.encodeJsonElement(buildJsonObject {
|
||||
put("id", value.id)
|
||||
put("title", value.title)
|
||||
put("type", value.type)
|
||||
put("url", value.url)
|
||||
value.thumbnail?.let { put("thumbnail", it) }
|
||||
put("source", value.source)
|
||||
})
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): DetailTrailer {
|
||||
val json = decoder as? JsonDecoder ?: error("DetailTrailer can only be deserialized from JSON")
|
||||
return detailTrailerFromJson(json.decodeJsonElement())
|
||||
}
|
||||
}
|
||||
|
||||
object MetaDetailSerializer : KSerializer<MetaDetail> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("MetaDetail", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: MetaDetail) {
|
||||
val json = encoder as? JsonEncoder ?: error("MetaDetail can only be serialized to JSON")
|
||||
val jsonInstance = json.json
|
||||
json.encodeJsonElement(buildJsonObject {
|
||||
put("id", value.id)
|
||||
put("type", value.type)
|
||||
put("name", value.name)
|
||||
value.genres?.let { put("genres", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.poster?.let { put("poster", it) }
|
||||
value.background?.let { put("background", it) }
|
||||
value.logo?.let { put("logo", it) }
|
||||
value.description?.let { put("description", it) }
|
||||
value.releaseInfo?.let { put("releaseInfo", it) }
|
||||
value.released?.let { put("released", it) }
|
||||
value.runtime?.let { put("runtime", it) }
|
||||
value.videos?.let { put("videos", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.trailers?.let { put("trailers", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.imdbRating?.let { put("imdbRating", it) }
|
||||
value.ageRating?.let { put("ageRating", it) }
|
||||
value.ratings?.let { put("ratings", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.cast?.let { put("cast", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.director?.let { put("director", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.links?.let { put("links", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.status?.let { put("status", it) }
|
||||
value.seasonsCount?.let { put("seasonsCount", it) }
|
||||
value.platforms?.let { put("platforms", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.awards?.let { put("awards", it) }
|
||||
value.originalLanguage?.let { put("originalLanguage", it) }
|
||||
value.originalName?.let { put("originalName", it) }
|
||||
value.country?.let { put("country", it) }
|
||||
value.productionCompanies?.let { put("productionCompanies", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.networks?.let { put("networks", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.collectionName?.let { put("collectionName", it) }
|
||||
value.collectionId?.let { put("collectionId", it) }
|
||||
value.seasonPosters?.let { put("seasonPosters", jsonInstance.encodeToJsonElement(it)) }
|
||||
value.appExtras?.let { put("appExtras", jsonInstance.encodeToJsonElement(it)) }
|
||||
})
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): MetaDetail {
|
||||
val json = decoder as? JsonDecoder ?: error("MetaDetail can only be deserialized from JSON")
|
||||
return metaDetailFromJson(json.decodeJsonElement())
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,6 @@ data class LibraryItemState(
|
|||
)
|
||||
|
||||
data class CatalogResponse(val metas: List<Meta>? = null)
|
||||
data class MetaDetailResponse(val meta: MetaDetail? = null)
|
||||
data class StreamResponse(val streams: List<Stream>? = null)
|
||||
|
||||
data class SubtitleResponse(val subtitles: List<SubtitleData>? = null) // Stremio format
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ interface StremioService {
|
|||
suspend fun getCatalogWithGenre(@Path("type") type: String, @Path("id") id: String, @Path("genre") genre: String): CatalogResponse
|
||||
|
||||
@GET("meta/{type}/{id}.json")
|
||||
suspend fun getMetaDetail(@Path("type") type: String, @Path("id") id: String): MetaDetailResponse
|
||||
suspend fun getMetaDetail(@Path("type") type: String, @Path("id") id: String): okhttp3.ResponseBody
|
||||
|
||||
@GET
|
||||
suspend fun getStreams(@Url url: String): StreamResponse
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import com.fluxa.app.core.rust.models.NativeAddonFetchResult
|
|||
import okhttp3.Request
|
||||
import com.fluxa.app.data.remote.AddonDescriptor
|
||||
import com.fluxa.app.data.remote.AuthRequest
|
||||
import com.fluxa.app.data.remote.CastMember
|
||||
import com.fluxa.app.data.remote.CastMemberDeserializer
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.MetaDetail
|
||||
import com.fluxa.app.data.remote.MetaDetailResponse
|
||||
|
|
@ -28,6 +26,9 @@ import kotlinx.coroutines.withContext
|
|||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.IOException
|
||||
import java.lang.reflect.Type
|
||||
|
|
@ -44,11 +45,9 @@ class StremioAddonResourceClient @Inject constructor(
|
|||
private val addonManifestClient: StremioAddonManifestClient,
|
||||
@param:Named("AddonResourceClient") private val httpClient: OkHttpClient
|
||||
) {
|
||||
private val stremioGson = GsonBuilder()
|
||||
.registerTypeAdapter(CastMember::class.java, CastMemberDeserializer())
|
||||
.create()
|
||||
private val stremioGson = GsonBuilder().create()
|
||||
private val stremioJson = Json { ignoreUnknownKeys = true; isLenient = true; coerceInputValues = true }
|
||||
private val streamListType = object : TypeToken<List<Stream>>() {}.type
|
||||
private val metaListType = object : TypeToken<List<Meta>>() {}.type
|
||||
private val subtitleListType = object : TypeToken<List<SubtitleData>>() {}.type
|
||||
private val userAddonsLocks = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
|
|
@ -61,7 +60,7 @@ class StremioAddonResourceClient @Inject constructor(
|
|||
.map(addonManifestClient::normalizeAddonTransportUrl)
|
||||
.filter { it.isNotBlank() }
|
||||
.distinctBy(StremioAddonUrls::identity)
|
||||
val cacheKey = "addons_v9_${authKey}_${normalizedLocalAddons.joinToString("|")}"
|
||||
val cacheKey = "addons_v10_${authKey}_${normalizedLocalAddons.joinToString("|")}"
|
||||
if (!forceRefresh) {
|
||||
cache.get<List<AddonDescriptor>>(cacheKey)?.let { return@withContext it }
|
||||
persistentCache.getUserAddons(cacheKey).takeIf { it.isNotEmpty() }?.let {
|
||||
|
|
@ -88,9 +87,9 @@ class StremioAddonResourceClient @Inject constructor(
|
|||
val liveManifest = withTimeoutOrNull(3000) {
|
||||
addonManifestClient.getAddonManifest(addon.transportUrl, forceRefresh)
|
||||
}
|
||||
addAddon(with(addonManifestClient) { addon.mergeLiveManifest(liveManifest) })
|
||||
with(addonManifestClient) { addon.mergeLiveManifest(liveManifest) }
|
||||
}
|
||||
}.awaitAll()
|
||||
}.awaitAll().forEach(::addAddon)
|
||||
} catch (e: Exception) {
|
||||
Log.w("StremioRepository", "Failed to load user addons", e)
|
||||
}
|
||||
|
|
@ -100,12 +99,13 @@ class StremioAddonResourceClient @Inject constructor(
|
|||
try {
|
||||
withTimeoutOrNull(3000) {
|
||||
addonManifestClient.getAddonManifest(url, forceRefresh)
|
||||
}?.let(::addAddon)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("StremioRepository", "Failed to load local addon manifest: $url", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}.awaitAll().filterNotNull().forEach(::addAddon)
|
||||
allAddons.toList().ifEmpty {
|
||||
persistentCache.getUserAddons(cacheKey)
|
||||
}.also {
|
||||
|
|
@ -250,13 +250,26 @@ class StremioAddonResourceClient @Inject constructor(
|
|||
fallbackUrl = addonManifestClient.buildAddonResourceUrl(transportUrl, "catalog", type, id, extraArgs)
|
||||
)
|
||||
val success = parsed as? AddonResourceResult.Success ?: return@withContext parsed.toTypedEmpty()
|
||||
val result = decodeResourceList<Meta>(result = success, type = metaListType)
|
||||
val result = decodeMetaList(success)
|
||||
if (result is AddonResourceResult.Success) {
|
||||
cache.put(cacheKey, result.value)
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
private fun decodeMetaList(result: AddonResourceResult.Success<String>): AddonResourceResult<List<Meta>> {
|
||||
return try {
|
||||
val items = stremioJson.decodeFromString<List<Meta>>(result.value)
|
||||
if (items.isEmpty()) AddonResourceResult.Empty(result.url) else AddonResourceResult.Success(items, result.url)
|
||||
} catch (e: SerializationException) {
|
||||
AddonResourceResult.ParseError(result.url, e)
|
||||
} catch (e: IOException) {
|
||||
AddonResourceResult.NetworkError(result.url, e)
|
||||
} catch (e: Exception) {
|
||||
AddonResourceResult.NetworkError(result.url, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> decodeResourceList(
|
||||
result: AddonResourceResult.Success<String>,
|
||||
type: Type,
|
||||
|
|
@ -285,7 +298,7 @@ class StremioAddonResourceClient @Inject constructor(
|
|||
?.applyLinks()
|
||||
?.let { AddonResourceResult.Success(it, result.url) }
|
||||
?: AddonResourceResult.Empty(result.url)
|
||||
} catch (e: JsonParseException) {
|
||||
} catch (e: SerializationException) {
|
||||
AddonResourceResult.ParseError(result.url, e)
|
||||
} catch (e: IOException) {
|
||||
AddonResourceResult.NetworkError(result.url, e)
|
||||
|
|
@ -295,8 +308,8 @@ class StremioAddonResourceClient @Inject constructor(
|
|||
}
|
||||
|
||||
private fun decodeMetaDetailPayload(json: String): MetaDetail? {
|
||||
return stremioGson.fromJson(json, MetaDetailResponse::class.java)?.meta
|
||||
?: stremioGson.fromJson(json, MetaDetail::class.java)
|
||||
return runCatching { stremioJson.decodeFromString<MetaDetailResponse>(json) }.getOrNull()?.meta
|
||||
?: runCatching { stremioJson.decodeFromString<MetaDetail>(json) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun MetaDetail.applyAppExtras(): MetaDetail {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@ import com.fluxa.app.domain.discovery.*
|
|||
import android.util.Log
|
||||
import com.fluxa.app.data.BuildConfig
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val stremioMetaJson = Json { ignoreUnknownKeys = true; isLenient = true; coerceInputValues = true }
|
||||
|
||||
@Singleton
|
||||
class StremioRepository @Inject constructor(
|
||||
private val authService: StremioService,
|
||||
|
|
@ -91,7 +95,12 @@ class StremioRepository @Inject constructor(
|
|||
Log.d("MetaFetch", "getMetaDetail fromAddon: ${if (fromAddon != null) "OK name=${fromAddon.name}" else "NULL"}")
|
||||
fromAddon?.let { return it }
|
||||
}
|
||||
return if (plan.fallbackToStremioMetaDetail) authService.getMetaDetail(type, id).meta else null
|
||||
return if (plan.fallbackToStremioMetaDetail) {
|
||||
val body = authService.getMetaDetail(type, id).string()
|
||||
runCatching { stremioMetaJson.decodeFromString<MetaDetailResponse>(body) }.getOrNull()?.meta
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAddonMetaDetail(
|
||||
|
|
|
|||
Loading…
Reference in a new issue