diff --git a/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt b/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt index cf1832a..834eca6 100644 --- a/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt +++ b/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt @@ -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 { diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidDetailDataSource.kt b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidDetailDataSource.kt index cded314..47a96d4 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidDetailDataSource.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidDetailDataSource.kt @@ -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() } }, diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidSettingsDataSource.kt b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidSettingsDataSource.kt index c5c028a..1d199d8 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/AndroidSettingsDataSource.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/AndroidSettingsDataSource.kt @@ -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, diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/DetailViewModel.kt b/app/src/main/java/com/fluxa/app/ui/catalog/DetailViewModel.kt index fef568d..11efac9 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/DetailViewModel.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/DetailViewModel.kt @@ -158,10 +158,15 @@ class DetailViewModel @Inject constructor( ).state["detail"] as? Map<*, *> if (generation != detailLoadGeneration) return@launch _uiState.update { + val mdblistRatings = gson.fromStateList(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) diff --git a/core/src/commonMain/resources/i18n/english_us.json b/core/src/commonMain/resources/i18n/english_us.json index 27bba03..a4f0b34 100644 --- a/core/src/commonMain/resources/i18n/english_us.json +++ b/core/src/commonMain/resources/i18n/english_us.json @@ -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", diff --git a/core/src/commonMain/resources/i18n/tr_tr.json b/core/src/commonMain/resources/i18n/tr_tr.json index 40a1b48..4f1d14e 100644 --- a/core/src/commonMain/resources/i18n/tr_tr.json +++ b/core/src/commonMain/resources/i18n/tr_tr.json @@ -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", diff --git a/data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt b/data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt index 3f8aebc..5ab6472 100644 --- a/data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt @@ -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 { + val value = FluxaCoreUniFfi.coreInvokeValue("mdblistMediaRatingsFromResponse", responseJson) + if (value.isJsonNull) return emptyMap() + return gson.fromJson(value, object : com.google.gson.reflect.TypeToken>() {}.type) + } + fun subtitleLanguageDedupKeepIndices(languages: List, maxPerLanguage: Int = 2): List { val args = JsonObject().apply { add("languages", gson.toJsonTree(languages)) diff --git a/data/src/androidMain/kotlin/com/fluxa/app/data/repository/MdblistRatingsClient.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/MdblistRatingsClient.kt new file mode 100644 index 0000000..89c5802 --- /dev/null +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/MdblistRatingsClient.kt @@ -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 = 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()) + } +} diff --git a/data/src/commonMain/kotlin/com/fluxa/app/data/local/UserProfile.kt b/data/src/commonMain/kotlin/com/fluxa/app/data/local/UserProfile.kt index 61561a9..a95e16b 100644 --- a/data/src/commonMain/kotlin/com/fluxa/app/data/local/UserProfile.kt +++ b/data/src/commonMain/kotlin/com/fluxa/app/data/local/UserProfile.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailContracts.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailContracts.kt index 93828b7..a3cf68f 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailContracts.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailContracts.kt @@ -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 = emptyList(), val runtimeLabel: String?, val ageRating: String? = null, val castNames: List = emptyList(), diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailScreen.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailScreen.kt index 38cce02..f7cf761 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/detail/DetailScreen.kt @@ -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) + ) + } + } + } } } } diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/settings/SettingsContracts.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/settings/SettingsContracts.kt index 42275e2..d8dda13 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/settings/SettingsContracts.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/settings/SettingsContracts.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/settings/SettingsScreen.kt index bc010e5..684ecf0 100644 --- a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/settings/SettingsScreen.kt @@ -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 }) {