feat: add MDBList detail ratings

This commit is contained in:
KhooLy 2026-07-29 15:46:30 +03:00
parent 32243b6363
commit 599e82e7ff
13 changed files with 127 additions and 2 deletions

View file

@ -37,6 +37,7 @@ import com.fluxa.app.player.TorrentStreamManager
import com.fluxa.app.player.TorrentStreamResult
import com.fluxa.app.data.repository.CloudStreamCatalogClient
import com.fluxa.app.data.repository.HttpEffectExecutor
import com.fluxa.app.data.repository.MdblistRatingsClient
import com.fluxa.app.data.repository.toStremioType
import com.fluxa.app.plugins.PluginManager
import com.fluxa.app.plugins.PluginRepositoryManager
@ -116,6 +117,7 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
internal val profileManager: ProfileManager,
internal val externalSyncPushCoordinator: ExternalSyncPushCoordinator,
internal val nuvioAccountImportCoordinator: NuvioAccountImportCoordinator,
internal val mdblistRatingsClient: MdblistRatingsClient,
internal val httpEffectExecutor: HttpEffectExecutor,
@param:Named("PluginScraperClient") internal val pluginScraperHttpClient: OkHttpClient
) : HeadlessPlatformEnvironment {
@ -294,7 +296,19 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
} else {
emptyList()
}
return ok(effect, mapOf("watchedVideoIds" to watchedIds.toList(), "similarItems" to similarItems, "trailers" to trailers))
val mdblistRatings = profile?.mdblistApiKey
?.takeIf(String::isNotBlank)
?.let { mdblistRatingsClient.fetch(type, id, it) }
.orEmpty()
return ok(
effect,
mapOf(
"watchedVideoIds" to watchedIds.toList(),
"similarItems" to similarItems,
"trailers" to trailers,
"mdblistRatings" to mdblistRatings
)
)
}
private suspend fun prefetchDetailStreams(effect: NativeHeadlessEffect): HeadlessEffectCompletion {

View file

@ -6,6 +6,7 @@ import com.fluxa.app.data.remote.Video
import com.fluxa.app.shared.feature.detail.DetailDataSource
import com.fluxa.app.shared.feature.detail.DetailEpisodeUiModel
import com.fluxa.app.shared.feature.detail.DetailRequestUiModel
import com.fluxa.app.shared.feature.detail.DetailRatingUiModel
import com.fluxa.app.shared.feature.detail.DetailStreamUiModel
import com.fluxa.app.shared.feature.detail.DetailUiModel
import com.fluxa.app.shared.feature.detail.DetailUiState as SharedDetailUiState
@ -55,6 +56,9 @@ class AndroidDetailDataSource(
trailerUrl = state.trailerUrl,
releaseLabel = detail.releaseInfo.orEmpty(),
ratingLabel = detail.imdbRating.orEmpty(),
ratings = detail.ratings.orEmpty().mapNotNull { rating ->
rating.value?.takeIf(String::isNotBlank)?.let { DetailRatingUiModel(rating.source, it) }
},
runtimeLabel = detail.runtime,
ageRating = detail.ageRating,
castNames = detail.cast.orEmpty().mapNotNull { it.name.takeIf { name -> name.isNotBlank() } },

View file

@ -147,6 +147,7 @@ class AndroidSettingsDataSource(
traktLibraryCount = profile.safeTraktLastWatchlistCount,
addonCount = profile.safeLocalAddons.size,
tmdbApiKey = profile.tmdbApiKey,
mdblistApiKey = profile.mdblistApiKey,
tmdbCastImagesEnabled = profile.safeTmdbCastImagesEnabled,
tmdbSimilarResultsEnabled = profile.safeTmdbSimilarResultsEnabled,
tmdbTrailersEnabled = profile.safeTmdbTrailersEnabled,
@ -408,6 +409,7 @@ class AndroidSettingsDataSource(
override suspend fun updateTmdbAccount(value: SettingsAccountUiModel) = update {
it.copy(
tmdbApiKey = value.tmdbApiKey,
mdblistApiKey = value.mdblistApiKey,
tmdbCastImagesEnabled = value.tmdbCastImagesEnabled,
tmdbSimilarResultsEnabled = value.tmdbSimilarResultsEnabled,
tmdbTrailersEnabled = value.tmdbTrailersEnabled,

View file

@ -158,10 +158,15 @@ class DetailViewModel @Inject constructor(
).state["detail"] as? Map<*, *>
if (generation != detailLoadGeneration) return@launch
_uiState.update {
val mdblistRatings = gson.fromStateList<MetaRating>(secondary?.get("mdblistRatings"))
it.copy(
watchedVideoIds = gson.fromStateList(secondary?.get("watchedVideoIds")),
similarItems = gson.fromStateList(secondary?.get("similarItems")),
trailers = if (it.trailers.isEmpty()) gson.fromStateList(secondary?.get("trailers")) else it.trailers
trailers = if (it.trailers.isEmpty()) gson.fromStateList(secondary?.get("trailers")) else it.trailers,
detail = it.detail?.copy(
ratings = (it.detail.ratings.orEmpty() + mdblistRatings)
.distinctBy { rating -> rating.source.lowercase() }
)
)
}
maybeAutoPlayDetailTrailer(generation, profile)

View file

@ -1032,6 +1032,9 @@
"settings.apis": "APIS",
"settings.apis_desc": "Configure metadata API access.",
"settings.tmdb_api": "TMDB API",
"settings.mdblist_api": "MDBList API",
"settings.mdblist_api_key": "MDBList API key",
"settings.mdblist_api_key_placeholder": "Enter API key",
"settings.tmdb_api_configured": "API key saved",
"settings.tmdb_api_not_configured": "Add API key",
"settings.tmdb_api_key": "TMDB API key",

View file

@ -1032,6 +1032,9 @@
"settings.apis": "API'LER",
"settings.apis_desc": "Metadata API erişimini yapılandır.",
"settings.tmdb_api": "TMDB API",
"settings.mdblist_api": "MDBList API",
"settings.mdblist_api_key": "MDBList API anahtarı",
"settings.mdblist_api_key_placeholder": "API anahtarını girin",
"settings.tmdb_api_configured": "API key kaydedildi",
"settings.tmdb_api_not_configured": "API key ekle",
"settings.tmdb_api_key": "TMDB API key",

View file

@ -1368,6 +1368,22 @@ object FluxaCoreNative {
return FluxaCoreUniFfi.coreInvokeValue("scrobbleCloseAction", args.toString()).asString
}
fun mdblistMediaInfoUrl(provider: String, mediaType: String, mediaId: String): String {
val args = JsonObject().apply {
addProperty("provider", provider)
addProperty("mediaType", mediaType)
addProperty("mediaId", mediaId)
addProperty("appendToResponse", "ratings")
}
return FluxaCoreUniFfi.coreInvokeValue("mdblistMediaInfoUrl", args.toString()).asString
}
fun mdblistMediaRatingsFromResponse(responseJson: String): Map<String, String> {
val value = FluxaCoreUniFfi.coreInvokeValue("mdblistMediaRatingsFromResponse", responseJson)
if (value.isJsonNull) return emptyMap()
return gson.fromJson(value, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type)
}
fun subtitleLanguageDedupKeepIndices(languages: List<String?>, maxPerLanguage: Int = 2): List<Int> {
val args = JsonObject().apply {
add("languages", gson.toJsonTree(languages))

View file

@ -0,0 +1,38 @@
package com.fluxa.app.data.repository
import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.data.remote.MetaRating
import com.fluxa.app.data.remote.StremioService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MdblistRatingsClient @Inject constructor() {
suspend fun fetch(contentType: String, contentId: String, apiKey: String): List<MetaRating> = withContext(Dispatchers.IO) {
if (apiKey.isBlank()) return@withContext emptyList()
val tmdbId = contentId.removePrefix("tmdb:").substringBefore(':')
val imdbId = contentId.substringBefore(':')
val providerAndId = when {
tmdbId.all(Char::isDigit) && tmdbId.isNotBlank() -> "tmdb" to tmdbId
imdbId.matches(Regex("(?i)tt\\d+")) -> "imdb" to imdbId
else -> return@withContext emptyList()
}
runCatching {
val url = FluxaCoreNative.mdblistMediaInfoUrl(
provider = providerAndId.first,
mediaType = if (contentType == "series") "show" else "movie",
mediaId = providerAndId.second
)
val request = Request.Builder().url("$url&apikey=$apiKey").get().build()
StremioService.sharedClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use emptyList()
val body = response.body.string()
FluxaCoreNative.mdblistMediaRatingsFromResponse(body)
.map { (source, value) -> MetaRating(source, value) }
}
}.getOrDefault(emptyList())
}
}

View file

@ -57,6 +57,7 @@ data class UserProfile(
val anilistRefreshToken: String? = null,
val anilistTokenExpiresAt: Long? = null,
val tmdbApiKey: String? = null,
val mdblistApiKey: String? = null,
val introDbApiKey: String? = null,
val tmdbCastImagesEnabled: Boolean? = true,
val tmdbSimilarResultsEnabled: Boolean? = true,

View file

@ -40,6 +40,11 @@ data class DetailStreamUiModel(
val name: String = ""
)
data class DetailRatingUiModel(
val source: String,
val value: String
)
data class DetailUiModel(
val id: String,
val type: String,
@ -51,6 +56,7 @@ data class DetailUiModel(
val trailerUrl: String? = null,
val releaseLabel: String,
val ratingLabel: String,
val ratings: List<DetailRatingUiModel> = emptyList(),
val runtimeLabel: String?,
val ageRating: String? = null,
val castNames: List<String> = emptyList(),

View file

@ -417,6 +417,27 @@ private fun Hero(content: DetailUiModel, language: String?) {
}
}
}
val ratings = content.ratings.ifEmpty {
content.ratingLabel.takeIf { it.isNotBlank() }?.let { listOf(DetailRatingUiModel("IMDb", it)) }.orEmpty()
}
if (ratings.isNotEmpty()) {
LazyRow(
modifier = Modifier.padding(top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(ratings, key = { "${it.source}:${it.value}" }) { rating ->
Text(
text = "${rating.source} ${rating.value}",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.background(Color.White.copy(alpha = 0.14f), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
}
}
}

View file

@ -31,6 +31,7 @@ data class SettingsAccountUiModel(
val traktLibraryCount: Int = 0,
val addonCount: Int = 0,
val tmdbApiKey: String? = null,
val mdblistApiKey: String? = null,
val tmdbCastImagesEnabled: Boolean = true,
val tmdbSimilarResultsEnabled: Boolean = true,
val tmdbTrailersEnabled: Boolean = true,

View file

@ -507,6 +507,17 @@ private fun SettingsAccountContent(
}
}
SettingsSectionHeader(AppStrings.t(lang, "settings.mdblist_api"))
SettingsGroupCard {
SettingsSecretFieldRow(
AppStrings.t(lang, "settings.mdblist_api_key"),
model.mdblistApiKey.orEmpty(),
placeholder = AppStrings.t(lang, "settings.mdblist_api_key_placeholder")
) {
onAction(SettingsAction.TmdbAccountChanged(model.copy(mdblistApiKey = it)))
}
}
selectedProvider?.let { provider ->
key(provider) {
SettingsAccountSheet(provider, model, lang, onDismiss = { selectedProvider = null }) {