mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-08 16:07:31 +00:00
feat: add MDBList detail ratings
This commit is contained in:
parent
32243b6363
commit
599e82e7ff
13 changed files with 127 additions and 2 deletions
|
|
@ -37,6 +37,7 @@ import com.fluxa.app.player.TorrentStreamManager
|
||||||
import com.fluxa.app.player.TorrentStreamResult
|
import com.fluxa.app.player.TorrentStreamResult
|
||||||
import com.fluxa.app.data.repository.CloudStreamCatalogClient
|
import com.fluxa.app.data.repository.CloudStreamCatalogClient
|
||||||
import com.fluxa.app.data.repository.HttpEffectExecutor
|
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.data.repository.toStremioType
|
||||||
import com.fluxa.app.plugins.PluginManager
|
import com.fluxa.app.plugins.PluginManager
|
||||||
import com.fluxa.app.plugins.PluginRepositoryManager
|
import com.fluxa.app.plugins.PluginRepositoryManager
|
||||||
|
|
@ -116,6 +117,7 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
|
||||||
internal val profileManager: ProfileManager,
|
internal val profileManager: ProfileManager,
|
||||||
internal val externalSyncPushCoordinator: ExternalSyncPushCoordinator,
|
internal val externalSyncPushCoordinator: ExternalSyncPushCoordinator,
|
||||||
internal val nuvioAccountImportCoordinator: NuvioAccountImportCoordinator,
|
internal val nuvioAccountImportCoordinator: NuvioAccountImportCoordinator,
|
||||||
|
internal val mdblistRatingsClient: MdblistRatingsClient,
|
||||||
internal val httpEffectExecutor: HttpEffectExecutor,
|
internal val httpEffectExecutor: HttpEffectExecutor,
|
||||||
@param:Named("PluginScraperClient") internal val pluginScraperHttpClient: OkHttpClient
|
@param:Named("PluginScraperClient") internal val pluginScraperHttpClient: OkHttpClient
|
||||||
) : HeadlessPlatformEnvironment {
|
) : HeadlessPlatformEnvironment {
|
||||||
|
|
@ -294,7 +296,19 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
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 {
|
private suspend fun prefetchDetailStreams(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||||
|
|
|
||||||
|
|
@ -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.DetailDataSource
|
||||||
import com.fluxa.app.shared.feature.detail.DetailEpisodeUiModel
|
import com.fluxa.app.shared.feature.detail.DetailEpisodeUiModel
|
||||||
import com.fluxa.app.shared.feature.detail.DetailRequestUiModel
|
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.DetailStreamUiModel
|
||||||
import com.fluxa.app.shared.feature.detail.DetailUiModel
|
import com.fluxa.app.shared.feature.detail.DetailUiModel
|
||||||
import com.fluxa.app.shared.feature.detail.DetailUiState as SharedDetailUiState
|
import com.fluxa.app.shared.feature.detail.DetailUiState as SharedDetailUiState
|
||||||
|
|
@ -55,6 +56,9 @@ class AndroidDetailDataSource(
|
||||||
trailerUrl = state.trailerUrl,
|
trailerUrl = state.trailerUrl,
|
||||||
releaseLabel = detail.releaseInfo.orEmpty(),
|
releaseLabel = detail.releaseInfo.orEmpty(),
|
||||||
ratingLabel = detail.imdbRating.orEmpty(),
|
ratingLabel = detail.imdbRating.orEmpty(),
|
||||||
|
ratings = detail.ratings.orEmpty().mapNotNull { rating ->
|
||||||
|
rating.value?.takeIf(String::isNotBlank)?.let { DetailRatingUiModel(rating.source, it) }
|
||||||
|
},
|
||||||
runtimeLabel = detail.runtime,
|
runtimeLabel = detail.runtime,
|
||||||
ageRating = detail.ageRating,
|
ageRating = detail.ageRating,
|
||||||
castNames = detail.cast.orEmpty().mapNotNull { it.name.takeIf { name -> name.isNotBlank() } },
|
castNames = detail.cast.orEmpty().mapNotNull { it.name.takeIf { name -> name.isNotBlank() } },
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ class AndroidSettingsDataSource(
|
||||||
traktLibraryCount = profile.safeTraktLastWatchlistCount,
|
traktLibraryCount = profile.safeTraktLastWatchlistCount,
|
||||||
addonCount = profile.safeLocalAddons.size,
|
addonCount = profile.safeLocalAddons.size,
|
||||||
tmdbApiKey = profile.tmdbApiKey,
|
tmdbApiKey = profile.tmdbApiKey,
|
||||||
|
mdblistApiKey = profile.mdblistApiKey,
|
||||||
tmdbCastImagesEnabled = profile.safeTmdbCastImagesEnabled,
|
tmdbCastImagesEnabled = profile.safeTmdbCastImagesEnabled,
|
||||||
tmdbSimilarResultsEnabled = profile.safeTmdbSimilarResultsEnabled,
|
tmdbSimilarResultsEnabled = profile.safeTmdbSimilarResultsEnabled,
|
||||||
tmdbTrailersEnabled = profile.safeTmdbTrailersEnabled,
|
tmdbTrailersEnabled = profile.safeTmdbTrailersEnabled,
|
||||||
|
|
@ -408,6 +409,7 @@ class AndroidSettingsDataSource(
|
||||||
override suspend fun updateTmdbAccount(value: SettingsAccountUiModel) = update {
|
override suspend fun updateTmdbAccount(value: SettingsAccountUiModel) = update {
|
||||||
it.copy(
|
it.copy(
|
||||||
tmdbApiKey = value.tmdbApiKey,
|
tmdbApiKey = value.tmdbApiKey,
|
||||||
|
mdblistApiKey = value.mdblistApiKey,
|
||||||
tmdbCastImagesEnabled = value.tmdbCastImagesEnabled,
|
tmdbCastImagesEnabled = value.tmdbCastImagesEnabled,
|
||||||
tmdbSimilarResultsEnabled = value.tmdbSimilarResultsEnabled,
|
tmdbSimilarResultsEnabled = value.tmdbSimilarResultsEnabled,
|
||||||
tmdbTrailersEnabled = value.tmdbTrailersEnabled,
|
tmdbTrailersEnabled = value.tmdbTrailersEnabled,
|
||||||
|
|
|
||||||
|
|
@ -158,10 +158,15 @@ class DetailViewModel @Inject constructor(
|
||||||
).state["detail"] as? Map<*, *>
|
).state["detail"] as? Map<*, *>
|
||||||
if (generation != detailLoadGeneration) return@launch
|
if (generation != detailLoadGeneration) return@launch
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
val mdblistRatings = gson.fromStateList<MetaRating>(secondary?.get("mdblistRatings"))
|
||||||
it.copy(
|
it.copy(
|
||||||
watchedVideoIds = gson.fromStateList(secondary?.get("watchedVideoIds")),
|
watchedVideoIds = gson.fromStateList(secondary?.get("watchedVideoIds")),
|
||||||
similarItems = gson.fromStateList(secondary?.get("similarItems")),
|
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)
|
maybeAutoPlayDetailTrailer(generation, profile)
|
||||||
|
|
|
||||||
|
|
@ -1032,6 +1032,9 @@
|
||||||
"settings.apis": "APIS",
|
"settings.apis": "APIS",
|
||||||
"settings.apis_desc": "Configure metadata API access.",
|
"settings.apis_desc": "Configure metadata API access.",
|
||||||
"settings.tmdb_api": "TMDB API",
|
"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_configured": "API key saved",
|
||||||
"settings.tmdb_api_not_configured": "Add API key",
|
"settings.tmdb_api_not_configured": "Add API key",
|
||||||
"settings.tmdb_api_key": "TMDB API key",
|
"settings.tmdb_api_key": "TMDB API key",
|
||||||
|
|
|
||||||
|
|
@ -1032,6 +1032,9 @@
|
||||||
"settings.apis": "API'LER",
|
"settings.apis": "API'LER",
|
||||||
"settings.apis_desc": "Metadata API erişimini yapılandır.",
|
"settings.apis_desc": "Metadata API erişimini yapılandır.",
|
||||||
"settings.tmdb_api": "TMDB API",
|
"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_configured": "API key kaydedildi",
|
||||||
"settings.tmdb_api_not_configured": "API key ekle",
|
"settings.tmdb_api_not_configured": "API key ekle",
|
||||||
"settings.tmdb_api_key": "TMDB API key",
|
"settings.tmdb_api_key": "TMDB API key",
|
||||||
|
|
|
||||||
|
|
@ -1368,6 +1368,22 @@ object FluxaCoreNative {
|
||||||
return FluxaCoreUniFfi.coreInvokeValue("scrobbleCloseAction", args.toString()).asString
|
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> {
|
fun subtitleLanguageDedupKeepIndices(languages: List<String?>, maxPerLanguage: Int = 2): List<Int> {
|
||||||
val args = JsonObject().apply {
|
val args = JsonObject().apply {
|
||||||
add("languages", gson.toJsonTree(languages))
|
add("languages", gson.toJsonTree(languages))
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,7 @@ data class UserProfile(
|
||||||
val anilistRefreshToken: String? = null,
|
val anilistRefreshToken: String? = null,
|
||||||
val anilistTokenExpiresAt: Long? = null,
|
val anilistTokenExpiresAt: Long? = null,
|
||||||
val tmdbApiKey: String? = null,
|
val tmdbApiKey: String? = null,
|
||||||
|
val mdblistApiKey: String? = null,
|
||||||
val introDbApiKey: String? = null,
|
val introDbApiKey: String? = null,
|
||||||
val tmdbCastImagesEnabled: Boolean? = true,
|
val tmdbCastImagesEnabled: Boolean? = true,
|
||||||
val tmdbSimilarResultsEnabled: Boolean? = true,
|
val tmdbSimilarResultsEnabled: Boolean? = true,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ data class DetailStreamUiModel(
|
||||||
val name: String = ""
|
val name: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class DetailRatingUiModel(
|
||||||
|
val source: String,
|
||||||
|
val value: String
|
||||||
|
)
|
||||||
|
|
||||||
data class DetailUiModel(
|
data class DetailUiModel(
|
||||||
val id: String,
|
val id: String,
|
||||||
val type: String,
|
val type: String,
|
||||||
|
|
@ -51,6 +56,7 @@ data class DetailUiModel(
|
||||||
val trailerUrl: String? = null,
|
val trailerUrl: String? = null,
|
||||||
val releaseLabel: String,
|
val releaseLabel: String,
|
||||||
val ratingLabel: String,
|
val ratingLabel: String,
|
||||||
|
val ratings: List<DetailRatingUiModel> = emptyList(),
|
||||||
val runtimeLabel: String?,
|
val runtimeLabel: String?,
|
||||||
val ageRating: String? = null,
|
val ageRating: String? = null,
|
||||||
val castNames: List<String> = emptyList(),
|
val castNames: List<String> = emptyList(),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ data class SettingsAccountUiModel(
|
||||||
val traktLibraryCount: Int = 0,
|
val traktLibraryCount: Int = 0,
|
||||||
val addonCount: Int = 0,
|
val addonCount: Int = 0,
|
||||||
val tmdbApiKey: String? = null,
|
val tmdbApiKey: String? = null,
|
||||||
|
val mdblistApiKey: String? = null,
|
||||||
val tmdbCastImagesEnabled: Boolean = true,
|
val tmdbCastImagesEnabled: Boolean = true,
|
||||||
val tmdbSimilarResultsEnabled: Boolean = true,
|
val tmdbSimilarResultsEnabled: Boolean = true,
|
||||||
val tmdbTrailersEnabled: Boolean = true,
|
val tmdbTrailersEnabled: Boolean = true,
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
selectedProvider?.let { provider ->
|
||||||
key(provider) {
|
key(provider) {
|
||||||
SettingsAccountSheet(provider, model, lang, onDismiss = { selectedProvider = null }) {
|
SettingsAccountSheet(provider, model, lang, onDismiss = { selectedProvider = null }) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue