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:
KhooLy 2026-07-16 00:36:08 +03:00
parent d4c9ae6edd
commit e5581918cd
6 changed files with 349 additions and 239 deletions

View file

@ -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 {

View file

@ -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())
}
}

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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(