feat: Add MDBList ratings integration

- Introduced MetaExternalRating data class to hold external ratings.
- Updated MetaDetails to include externalRatings list.
- Implemented MdbListMetadataService for fetching ratings from MDBList API.
- Created MdbListSettings and MdbListSettingsRepository for managing settings.
- Added UI components for displaying external ratings in DetailMetaInfo.
- Integrated MDBList settings into the settings screen with options for enabling/disabling and configuring providers.
- Updated ProfileRepository to handle MDBList settings changes.
- Enhanced error handling and caching for MDBList API requests.
This commit is contained in:
tapframe 2026-04-01 12:48:46 +05:30
parent 3340aae0b8
commit 5fcbd87522
28 changed files with 985 additions and 57 deletions

View file

@ -7,7 +7,7 @@ import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
import java.util.Properties
abstract class GenerateRuntimeConfigsTask : DefaultTask() {
@ -77,7 +77,7 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generat
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
}
tasks.withType<KotlinCompile>().configureEach {
tasks.withType<KotlinCompilationTask<*>>().configureEach {
dependsOn(generateRuntimeConfigs)
}

View file

@ -10,6 +10,7 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.library.LibraryStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
import com.nuvio.app.features.mdblist.MdbListSettingsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.profiles.ProfileStorage
import com.nuvio.app.features.details.SeasonViewModeStorage
@ -43,6 +44,7 @@ class MainActivity : ComponentActivity() {
SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext)
MdbListSettingsStorage.initialize(applicationContext)
ContinueWatchingPreferencesStorage.initialize(applicationContext)
WatchProgressStorage.initialize(applicationContext)
StreamLinkCacheStorage.initialize(applicationContext)

View file

@ -10,6 +10,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_player_settings",
"nuvio_profile_cache",
"nuvio_theme_settings",
"nuvio_mdblist_settings",
"nuvio_watched",
"nuvio_stream_link_cache",
"nuvio_continue_watching_preferences",

View file

@ -2,11 +2,18 @@ package com.nuvio.app.features.addons
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.net.HttpURLConnection
import java.net.URL
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.isSuccess
actual object AddonStorage {
private const val preferencesName = "nuvio_addons"
@ -35,27 +42,45 @@ actual object AddonStorage {
}
}
actual suspend fun httpGetText(url: String): String =
withContext(Dispatchers.IO) {
val connection = URL(url).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.setRequestProperty("Accept", "application/json")
private val addonHttpClient = HttpClient(Android) {
install(HttpTimeout) {
requestTimeoutMillis = 10_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 10_000
}
expectSuccess = false
}
try {
val statusCode = connection.responseCode
val stream = if (statusCode in 200..299) {
connection.inputStream
} else {
connection.errorStream ?: connection.inputStream
actual suspend fun httpGetText(url: String): String =
addonHttpClient
.get(url) {
accept(ContentType.Application.Json)
}
.let { response ->
val payload = response.bodyAsText()
if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}")
}
val payload = stream.bufferedReader().use(BufferedReader::readText)
if (statusCode !in 200..299) {
error("Request failed with HTTP $statusCode")
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
}
payload
}
actual suspend fun httpPostJson(url: String, body: String): String =
addonHttpClient
.post(url) {
accept(ContentType.Application.Json)
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(body)
}
.let { response ->
val payload = response.bodyAsText()
if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}")
}
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
}
payload
} finally {
connection.disconnect()
}
}

View file

@ -0,0 +1,99 @@
package com.nuvio.app.features.mdblist
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
actual object MdbListSettingsStorage {
private const val preferencesName = "nuvio_mdblist_settings"
private const val enabledKey = "mdblist_enabled"
private const val apiKey = "mdblist_api_key"
private const val useImdbKey = "mdblist_use_imdb"
private const val useTmdbKey = "mdblist_use_tmdb"
private const val useTomatoesKey = "mdblist_use_tomatoes"
private const val useMetacriticKey = "mdblist_use_metacritic"
private const val useTraktKey = "mdblist_use_trakt"
private const val useLetterboxdKey = "mdblist_use_letterboxd"
private const val useAudienceKey = "mdblist_use_audience"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
actual fun saveEnabled(enabled: Boolean) {
saveBoolean(enabledKey, enabled)
}
actual fun loadApiKey(): String? =
preferences?.getString(ProfileScopedKey.of(apiKey), null)
actual fun saveApiKey(apiKey: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(this.apiKey), apiKey)
?.apply()
}
actual fun loadUseImdb(): Boolean? = loadBoolean(useImdbKey)
actual fun saveUseImdb(enabled: Boolean) {
saveBoolean(useImdbKey, enabled)
}
actual fun loadUseTmdb(): Boolean? = loadBoolean(useTmdbKey)
actual fun saveUseTmdb(enabled: Boolean) {
saveBoolean(useTmdbKey, enabled)
}
actual fun loadUseTomatoes(): Boolean? = loadBoolean(useTomatoesKey)
actual fun saveUseTomatoes(enabled: Boolean) {
saveBoolean(useTomatoesKey, enabled)
}
actual fun loadUseMetacritic(): Boolean? = loadBoolean(useMetacriticKey)
actual fun saveUseMetacritic(enabled: Boolean) {
saveBoolean(useMetacriticKey, enabled)
}
actual fun loadUseTrakt(): Boolean? = loadBoolean(useTraktKey)
actual fun saveUseTrakt(enabled: Boolean) {
saveBoolean(useTraktKey, enabled)
}
actual fun loadUseLetterboxd(): Boolean? = loadBoolean(useLetterboxdKey)
actual fun saveUseLetterboxd(enabled: Boolean) {
saveBoolean(useLetterboxdKey, enabled)
}
actual fun loadUseAudience(): Boolean? = loadBoolean(useAudienceKey)
actual fun saveUseAudience(enabled: Boolean) {
saveBoolean(useAudienceKey, enabled)
}
private fun loadBoolean(key: String): Boolean? =
preferences?.let { sharedPreferences ->
val scopedKey = ProfileScopedKey.of(key)
if (sharedPreferences.contains(scopedKey)) {
sharedPreferences.getBoolean(scopedKey, false)
} else {
null
}
}
private fun saveBoolean(key: String, enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(key), enabled)
?.apply()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -6,3 +6,5 @@ internal expect object AddonStorage {
}
expect suspend fun httpGetText(url: String): String
expect suspend fun httpPostJson(url: String, body: String): String

View file

@ -18,6 +18,7 @@ data class MetaDetails(
val imdbRating: String? = null,
val ageRating: String? = null,
val runtime: String? = null,
val externalRatings: List<MetaExternalRating> = emptyList(),
val genres: List<String> = emptyList(),
val director: List<String> = emptyList(),
val writer: List<String> = emptyList(),
@ -37,6 +38,11 @@ data class MetaDetails(
val videos: List<MetaVideo> = emptyList(),
)
data class MetaExternalRating(
val source: String,
val value: Double,
)
data class MetaTrailer(
val id: String,
val key: String,

View file

@ -4,8 +4,11 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.mdblist.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -120,6 +123,7 @@ object MetaDetailsRepository {
private const val FETCH_TIMEOUT_MS = 5_000L
private const val TMDB_ENRICH_TIMEOUT_MS = 5_000L
private const val MDBLIST_ENRICH_TIMEOUT_MS = 5_000L
private suspend fun tryFetchMeta(
manifest: AddonManifest,
@ -128,6 +132,7 @@ object MetaDetailsRepository {
): MetaDetails? {
return try {
TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded()
val baseUrl = manifest.transportUrl
.substringBefore("?")
.removeSuffix("/manifest.json")
@ -136,13 +141,20 @@ object MetaDetailsRepository {
val payload = httpGetText(url)
log.d { "Raw payload length=${payload.length}, first 500 chars: ${payload.take(500)}" }
val result = MetaDetailsParser.parse(payload)
val enriched = withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) {
val tmdbEnriched = withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) {
TmdbMetadataService.enrichMeta(
meta = result,
fallbackItemId = id,
settings = TmdbSettingsRepository.snapshot(),
)
} ?: result
val enriched = withTimeoutOrNull(MDBLIST_ENRICH_TIMEOUT_MS) {
MdbListMetadataService.enrichMeta(
meta = tmdbEnriched,
fallbackItemId = id,
settings = MdbListSettingsRepository.snapshot(),
)
} ?: tmdbEnriched
log.d { "Parsed meta: type=${enriched.type}, name=${enriched.name}, videos=${enriched.videos.size}" }
if (enriched.videos.isNotEmpty()) {
val first = enriched.videos.first()
@ -150,6 +162,7 @@ object MetaDetailsRepository {
}
enriched
} catch (e: Throwable) {
if (e is CancellationException) throw e
log.e(e) { "Failed to fetch/parse meta from ${manifest.transportUrl}" }
null
}

View file

@ -1,8 +1,10 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -10,8 +12,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -29,7 +33,27 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaExternalRating
import com.nuvio.app.features.details.formatMetaReleaseLineForDetails
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_AUDIENCE
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_IMDB
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_LETTERBOXD
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_METACRITIC
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TMDB
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES
import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TRAKT
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.rating_audience_score
import nuvio.composeapp.generated.resources.rating_imdb
import nuvio.composeapp.generated.resources.rating_letterboxd
import nuvio.composeapp.generated.resources.rating_metacritic
import nuvio.composeapp.generated.resources.rating_rotten_tomatoes
import nuvio.composeapp.generated.resources.rating_tmdb
import nuvio.composeapp.generated.resources.rating_trakt
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@Composable
fun DetailMetaInfo(
@ -43,10 +67,11 @@ fun DetailMetaInfo(
val releaseLine = formatMetaReleaseLineForDetails(meta)
val runtimeText = meta.runtime?.trim()?.takeIf { it.isNotBlank() }?.uppercase()
val ageBadge = meta.ageRating?.trim()?.takeIf { it.isNotBlank() }
val hasMdbImdbRating = meta.externalRatings.any { it.source == PROVIDER_IMDB }
val hasMetaRow = releaseLine != null ||
runtimeText != null ||
ageBadge != null ||
meta.imdbRating != null
(meta.imdbRating != null && !hasMdbImdbRating)
if (hasMetaRow) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -71,7 +96,7 @@ fun DetailMetaInfo(
ageBadge?.let { badge ->
DetailHeroMetaBadge(text = badge)
}
if (meta.imdbRating != null) {
if (meta.imdbRating != null && !hasMdbImdbRating) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
@ -102,6 +127,12 @@ fun DetailMetaInfo(
}
}
if (meta.externalRatings.isNotEmpty()) {
DetailRatingsRow(
ratings = meta.externalRatings,
)
}
if (meta.director.isNotEmpty()) {
MetaLabelValueRow(
label = "Director",
@ -139,6 +170,47 @@ fun DetailMetaInfo(
}
}
@Composable
private fun DetailRatingsRow(
ratings: List<MetaExternalRating>,
) {
val orderedRatings = remember(ratings) {
val bySource = ratings.associateBy { it.source }
ratingVisuals.mapNotNull { visuals ->
bySource[visuals.source]?.let { rating -> visuals to rating }
}
}
if (orderedRatings.isEmpty()) return
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
orderedRatings.forEach { (visuals, rating) ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(visuals.logo),
contentDescription = visuals.displayName,
modifier = Modifier.size(width = visuals.logoWidth, height = 16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = visuals.format(rating.value),
style = MaterialTheme.typography.titleSmall,
color = visuals.valueColor,
fontWeight = FontWeight.Bold,
)
}
}
}
}
@Composable
private fun MetaLabelValueRow(
label: String,
@ -185,3 +257,82 @@ private fun DetailHeroMetaBadge(
private val ImdbYellow = Color(0xFFF5C518)
private val ImdbBlack = Color(0xFF000000)
private data class RatingVisuals(
val source: String,
val displayName: String,
val logo: DrawableResource,
val logoWidth: androidx.compose.ui.unit.Dp,
val valueColor: Color,
val format: (Double) -> String,
)
private val ratingVisuals = listOf(
RatingVisuals(
source = PROVIDER_IMDB,
displayName = "IMDb",
logo = Res.drawable.rating_imdb,
logoWidth = 30.dp,
valueColor = Color(0xFFF5C518),
format = ::formatOneDecimal,
),
RatingVisuals(
source = PROVIDER_TMDB,
displayName = "TMDB",
logo = Res.drawable.rating_tmdb,
logoWidth = 16.dp,
valueColor = Color(0xFF01B4E4),
format = ::formatWhole,
),
RatingVisuals(
source = PROVIDER_TOMATOES,
displayName = "Rotten Tomatoes",
logo = Res.drawable.rating_rotten_tomatoes,
logoWidth = 16.dp,
valueColor = Color(0xFFFA320A),
format = ::formatPercent,
),
RatingVisuals(
source = PROVIDER_METACRITIC,
displayName = "Metacritic",
logo = Res.drawable.rating_metacritic,
logoWidth = 16.dp,
valueColor = Color(0xFFFFCC33),
format = ::formatWhole,
),
RatingVisuals(
source = PROVIDER_TRAKT,
displayName = "Trakt",
logo = Res.drawable.rating_trakt,
logoWidth = 16.dp,
valueColor = Color(0xFFED1C24),
format = ::formatWhole,
),
RatingVisuals(
source = PROVIDER_LETTERBOXD,
displayName = "Letterboxd",
logo = Res.drawable.rating_letterboxd,
logoWidth = 16.dp,
valueColor = Color(0xFF00E054),
format = ::formatOneDecimal,
),
RatingVisuals(
source = PROVIDER_AUDIENCE,
displayName = "Audience Score",
logo = Res.drawable.rating_audience_score,
logoWidth = 16.dp,
valueColor = Color(0xFFFA320A),
format = ::formatPercent,
),
)
private fun formatOneDecimal(value: Double): String {
val rounded = (value * 10.0).roundToInt()
val whole = rounded / 10
val decimal = (rounded % 10).absoluteValue
return "$whole.$decimal"
}
private fun formatWhole(value: Double): String = value.roundToInt().toString()
private fun formatPercent(value: Double): String = "${value.roundToInt()}%"

View file

@ -0,0 +1,148 @@
package com.nuvio.app.features.mdblist
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpPostJson
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaExternalRating
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object MdbListMetadataService {
const val PROVIDER_IMDB = "imdb"
const val PROVIDER_TMDB = "tmdb"
const val PROVIDER_TOMATOES = "tomatoes"
const val PROVIDER_METACRITIC = "metacritic"
const val PROVIDER_TRAKT = "trakt"
const val PROVIDER_LETTERBOXD = "letterboxd"
const val PROVIDER_AUDIENCE = "audience"
val PROVIDER_PRIORITY_ORDER = listOf(
PROVIDER_IMDB,
PROVIDER_TMDB,
PROVIDER_TOMATOES,
PROVIDER_METACRITIC,
PROVIDER_TRAKT,
PROVIDER_LETTERBOXD,
PROVIDER_AUDIENCE,
)
private val log = Logger.withTag("MdbListMetadata")
private val json = Json { ignoreUnknownKeys = true }
private val ratingsCache = mutableMapOf<String, List<MetaExternalRating>>()
private val imdbRegex = Regex("tt\\d+")
suspend fun enrichMeta(
meta: MetaDetails,
fallbackItemId: String,
settings: MdbListSettings,
): MetaDetails {
if (!settings.enabled) return meta.copy(externalRatings = emptyList())
val apiKey = settings.apiKey.trim()
if (apiKey.isBlank()) return meta.copy(externalRatings = emptyList())
val imdbId = extractImdbId(meta.id)
?: extractImdbId(fallbackItemId)
?: return meta.copy(externalRatings = emptyList())
val mediaType = toMdbListMediaType(meta.type)
val enabledProviders = settings.enabledProvidersInPriorityOrder()
if (enabledProviders.isEmpty()) return meta.copy(externalRatings = emptyList())
val ratings = fetchRatings(
imdbId = imdbId,
mediaType = mediaType,
apiKey = apiKey,
providers = enabledProviders,
)
return meta.copy(externalRatings = ratings)
}
fun clearCache() {
ratingsCache.clear()
}
private suspend fun fetchRatings(
imdbId: String,
mediaType: String,
apiKey: String,
providers: List<String>,
): List<MetaExternalRating> = withContext(Dispatchers.Default) {
val cacheKey = "$mediaType:$imdbId:$apiKey:${providers.joinToString(",")}"
ratingsCache[cacheKey]?.let { return@withContext it }
val ratings = coroutineScope {
providers.map { providerId ->
async {
fetchProviderRating(
imdbId = imdbId,
mediaType = mediaType,
providerId = providerId,
apiKey = apiKey,
)
}
}.awaitAll().filterNotNull()
}
ratingsCache[cacheKey] = ratings
ratings
}
private suspend fun fetchProviderRating(
imdbId: String,
mediaType: String,
providerId: String,
apiKey: String,
): MetaExternalRating? {
val url = "https://api.mdblist.com/rating/$mediaType/$providerId?apikey=$apiKey"
val requestBody = json.encodeToString(
RatingRequest(
ids = listOf(imdbId),
provider = PROVIDER_IMDB,
),
)
return runCatching {
val payload = httpPostJson(url = url, body = requestBody)
val parsed = json.decodeFromString<RatingResponse>(payload)
val rating = parsed.ratings.firstOrNull()?.rating ?: return@runCatching null
MetaExternalRating(source = providerId, value = rating)
}.onFailure { error ->
if (error is CancellationException) throw error
log.w { "MDBList request failed for $providerId/$imdbId: ${error.message}" }
}.getOrNull()
}
private fun extractImdbId(value: String?): String? {
if (value.isNullOrBlank()) return null
return imdbRegex.find(value)?.value
}
private fun toMdbListMediaType(metaType: String): String {
val normalized = metaType.trim().lowercase()
return if (normalized == "movie") "movie" else "show"
}
}
@Serializable
private data class RatingRequest(
val ids: List<String>,
val provider: String,
)
@Serializable
private data class RatingResponse(
val ratings: List<RatingItem> = emptyList(),
)
@Serializable
private data class RatingItem(
val rating: Double? = null,
)

View file

@ -0,0 +1,28 @@
package com.nuvio.app.features.mdblist
data class MdbListSettings(
val enabled: Boolean = false,
val apiKey: String = "",
val useImdb: Boolean = true,
val useTmdb: Boolean = true,
val useTomatoes: Boolean = true,
val useMetacritic: Boolean = true,
val useTrakt: Boolean = true,
val useLetterboxd: Boolean = true,
val useAudience: Boolean = true,
) {
fun isProviderEnabled(providerId: String): Boolean =
when (providerId) {
MdbListMetadataService.PROVIDER_IMDB -> useImdb
MdbListMetadataService.PROVIDER_TMDB -> useTmdb
MdbListMetadataService.PROVIDER_TOMATOES -> useTomatoes
MdbListMetadataService.PROVIDER_METACRITIC -> useMetacritic
MdbListMetadataService.PROVIDER_TRAKT -> useTrakt
MdbListMetadataService.PROVIDER_LETTERBOXD -> useLetterboxd
MdbListMetadataService.PROVIDER_AUDIENCE -> useAudience
else -> false
}
fun enabledProvidersInPriorityOrder(): List<String> =
MdbListMetadataService.PROVIDER_PRIORITY_ORDER.filter(::isProviderEnabled)
}

View file

@ -0,0 +1,119 @@
package com.nuvio.app.features.mdblist
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object MdbListSettingsRepository {
private val _uiState = MutableStateFlow(MdbListSettings())
val uiState: StateFlow<MdbListSettings> = _uiState.asStateFlow()
private var hasLoaded = false
private var enabled = false
private var apiKey = ""
private var useImdb = true
private var useTmdb = true
private var useTomatoes = true
private var useMetacritic = true
private var useTrakt = true
private var useLetterboxd = true
private var useAudience = true
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
fun onProfileChanged() {
loadFromDisk()
}
fun snapshot(): MdbListSettings {
ensureLoaded()
return _uiState.value
}
fun setEnabled(value: Boolean) {
ensureLoaded()
if (enabled == value) return
enabled = value
publish()
MdbListSettingsStorage.saveEnabled(value)
}
fun setApiKey(value: String) {
ensureLoaded()
val normalized = value.trim()
if (apiKey == normalized) return
apiKey = normalized
publish()
MdbListSettingsStorage.saveApiKey(normalized)
MdbListMetadataService.clearCache()
}
fun setProviderEnabled(providerId: String, value: Boolean) {
ensureLoaded()
when (providerId) {
MdbListMetadataService.PROVIDER_IMDB -> if (useImdb != value) {
useImdb = value
MdbListSettingsStorage.saveUseImdb(value)
} else return
MdbListMetadataService.PROVIDER_TMDB -> if (useTmdb != value) {
useTmdb = value
MdbListSettingsStorage.saveUseTmdb(value)
} else return
MdbListMetadataService.PROVIDER_TOMATOES -> if (useTomatoes != value) {
useTomatoes = value
MdbListSettingsStorage.saveUseTomatoes(value)
} else return
MdbListMetadataService.PROVIDER_METACRITIC -> if (useMetacritic != value) {
useMetacritic = value
MdbListSettingsStorage.saveUseMetacritic(value)
} else return
MdbListMetadataService.PROVIDER_TRAKT -> if (useTrakt != value) {
useTrakt = value
MdbListSettingsStorage.saveUseTrakt(value)
} else return
MdbListMetadataService.PROVIDER_LETTERBOXD -> if (useLetterboxd != value) {
useLetterboxd = value
MdbListSettingsStorage.saveUseLetterboxd(value)
} else return
MdbListMetadataService.PROVIDER_AUDIENCE -> if (useAudience != value) {
useAudience = value
MdbListSettingsStorage.saveUseAudience(value)
} else return
else -> return
}
publish()
MdbListMetadataService.clearCache()
}
private fun loadFromDisk() {
hasLoaded = true
enabled = MdbListSettingsStorage.loadEnabled() ?: false
apiKey = MdbListSettingsStorage.loadApiKey().orEmpty().trim()
useImdb = MdbListSettingsStorage.loadUseImdb() ?: true
useTmdb = MdbListSettingsStorage.loadUseTmdb() ?: true
useTomatoes = MdbListSettingsStorage.loadUseTomatoes() ?: true
useMetacritic = MdbListSettingsStorage.loadUseMetacritic() ?: true
useTrakt = MdbListSettingsStorage.loadUseTrakt() ?: true
useLetterboxd = MdbListSettingsStorage.loadUseLetterboxd() ?: true
useAudience = MdbListSettingsStorage.loadUseAudience() ?: true
publish()
}
private fun publish() {
_uiState.value = MdbListSettings(
enabled = enabled,
apiKey = apiKey,
useImdb = useImdb,
useTmdb = useTmdb,
useTomatoes = useTomatoes,
useMetacritic = useMetacritic,
useTrakt = useTrakt,
useLetterboxd = useLetterboxd,
useAudience = useAudience,
)
}
}

View file

@ -0,0 +1,22 @@
package com.nuvio.app.features.mdblist
internal expect object MdbListSettingsStorage {
fun loadEnabled(): Boolean?
fun saveEnabled(enabled: Boolean)
fun loadApiKey(): String?
fun saveApiKey(apiKey: String)
fun loadUseImdb(): Boolean?
fun saveUseImdb(enabled: Boolean)
fun loadUseTmdb(): Boolean?
fun saveUseTmdb(enabled: Boolean)
fun loadUseTomatoes(): Boolean?
fun saveUseTomatoes(enabled: Boolean)
fun loadUseMetacritic(): Boolean?
fun saveUseMetacritic(enabled: Boolean)
fun loadUseTrakt(): Boolean?
fun saveUseTrakt(enabled: Boolean)
fun loadUseLetterboxd(): Boolean?
fun saveUseLetterboxd(enabled: Boolean)
fun loadUseAudience(): Boolean?
fun saveUseAudience(enabled: Boolean)
}

View file

@ -7,6 +7,7 @@ import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.search.SearchHistoryRepository
import com.nuvio.app.features.settings.ThemeSettingsRepository
@ -125,6 +126,7 @@ object ProfileRepository {
HomeCatalogSettingsRepository.onProfileChanged()
ContinueWatchingPreferencesRepository.onProfileChanged()
TmdbSettingsRepository.onProfileChanged()
MdbListSettingsRepository.onProfileChanged()
SearchHistoryRepository.onProfileChanged()
}

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.MovieFilter
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.Tune
internal fun LazyListScope.contentDiscoveryContent(
@ -11,6 +12,7 @@ internal fun LazyListScope.contentDiscoveryContent(
onAddonsClick: () -> Unit,
onHomescreenClick: () -> Unit,
onTmdbClick: () -> Unit,
onMdbListClick: () -> Unit,
) {
item {
SettingsSection(
@ -41,6 +43,14 @@ internal fun LazyListScope.contentDiscoveryContent(
isTablet = isTablet,
onClick = onTmdbClick,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = "MDBList Ratings",
description = "Add IMDb, Rotten Tomatoes, Metacritic, and other external ratings to details pages.",
icon = Icons.Rounded.Star,
isTablet = isTablet,
onClick = onMdbListClick,
)
}
}
}

View file

@ -0,0 +1,162 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.mdblist.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettings
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
internal fun LazyListScope.mdbListSettingsContent(
isTablet: Boolean,
settings: MdbListSettings,
) {
item {
SettingsSection(
title = "MDBLIST",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = "Enable MDBList ratings",
description = "Show external ratings from MDBList on metadata pages when an IMDb ID is available.",
checked = settings.enabled,
isTablet = isTablet,
onCheckedChange = MdbListSettingsRepository::setEnabled,
)
}
}
}
item {
SettingsSection(
title = "API KEY",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
MdbListApiKeyRow(
isTablet = isTablet,
value = settings.apiKey,
enabled = settings.enabled,
onApiKeyCommitted = MdbListSettingsRepository::setApiKey,
)
}
}
}
item {
SettingsSection(
title = "RATING PROVIDERS",
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
ProviderRows(
isTablet = isTablet,
settings = settings,
)
}
}
}
}
@Composable
private fun ProviderRows(
isTablet: Boolean,
settings: MdbListSettings,
) {
val providers = listOf(
MdbListMetadataService.PROVIDER_IMDB to "IMDb",
MdbListMetadataService.PROVIDER_TMDB to "TMDB",
MdbListMetadataService.PROVIDER_TOMATOES to "Rotten Tomatoes",
MdbListMetadataService.PROVIDER_METACRITIC to "Metacritic",
MdbListMetadataService.PROVIDER_TRAKT to "Trakt",
MdbListMetadataService.PROVIDER_LETTERBOXD to "Letterboxd",
MdbListMetadataService.PROVIDER_AUDIENCE to "Audience Score",
)
providers.forEachIndexed { index, (providerId, providerLabel) ->
SettingsSwitchRow(
title = providerLabel,
checked = settings.isProviderEnabled(providerId),
enabled = settings.enabled,
isTablet = isTablet,
onCheckedChange = { checked ->
MdbListSettingsRepository.setProviderEnabled(providerId, checked)
},
)
if (index < providers.lastIndex) {
SettingsGroupDivider(isTablet = isTablet)
}
}
}
@Composable
private fun MdbListApiKeyRow(
isTablet: Boolean,
value: String,
enabled: Boolean,
onApiKeyCommitted: (String) -> Unit,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
val verticalPadding = if (isTablet) 16.dp else 14.dp
var draft by rememberSaveable(value) { mutableStateOf(value) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "MDBList API key",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
Text(
text = "Get a key from https://mdblist.com/preferences and paste it here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedTextField(
value = draft,
onValueChange = {
draft = it
if (enabled) {
onApiKeyCommitted(it)
}
},
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("API key") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f),
unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
}

View file

@ -19,6 +19,7 @@ internal enum class SettingsPage(
Appearance("Appearance"),
ContentDiscovery("Content & Discovery"),
TmdbEnrichment("TMDB Enrichment"),
MdbListRatings("MDBList Ratings"),
}
internal fun SettingsPage.previousPage(): SettingsPage? =
@ -28,4 +29,5 @@ internal fun SettingsPage.previousPage(): SettingsPage? =
SettingsPage.Appearance -> SettingsPage.Root
SettingsPage.ContentDiscovery -> SettingsPage.Root
SettingsPage.TmdbEnrichment -> SettingsPage.ContentDiscovery
SettingsPage.MdbListRatings -> SettingsPage.ContentDiscovery
}

View file

@ -36,6 +36,8 @@ import com.nuvio.app.core.ui.AppTheme
import com.nuvio.app.core.ui.PlatformBackHandler
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.features.mdblist.MdbListSettings
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.tmdb.TmdbSettings
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
@ -68,6 +70,10 @@ fun SettingsScreen(
TmdbSettingsRepository.ensureLoaded()
TmdbSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val mdbListSettings by remember {
MdbListSettingsRepository.ensureLoaded()
MdbListSettingsRepository.uiState
}.collectAsStateWithLifecycle()
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
@ -97,6 +103,7 @@ fun SettingsScreen(
amoledEnabled = amoledEnabled,
onAmoledToggle = ThemeSettingsRepository::setAmoled,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenClick,
onContinueWatchingClick = onContinueWatchingClick,
@ -122,6 +129,7 @@ fun SettingsScreen(
amoledEnabled = amoledEnabled,
onAmoledToggle = ThemeSettingsRepository::setAmoled,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenClick,
onContinueWatchingClick = onContinueWatchingClick,
@ -151,6 +159,7 @@ private fun MobileSettingsScreen(
amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
onSwitchProfile: (() -> Unit)? = null,
onHomescreenClick: () -> Unit = {},
onContinueWatchingClick: () -> Unit = {},
@ -201,11 +210,16 @@ private fun MobileSettingsScreen(
onAddonsClick = onAddonsClick,
onHomescreenClick = onHomescreenClick,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = false,
settings = tmdbSettings,
)
SettingsPage.MdbListRatings -> mdbListSettingsContent(
isTablet = false,
settings = mdbListSettings,
)
}
}
}
@ -229,6 +243,7 @@ private fun TabletSettingsScreen(
amoledEnabled: Boolean,
onAmoledToggle: (Boolean) -> Unit,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
onSwitchProfile: (() -> Unit)? = null,
onHomescreenClick: () -> Unit = {},
onContinueWatchingClick: () -> Unit = {},
@ -328,11 +343,16 @@ private fun TabletSettingsScreen(
onAddonsClick = onAddonsClick,
onHomescreenClick = onHomescreenClick,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = true,
settings = tmdbSettings,
)
SettingsPage.MdbListRatings -> mdbListSettingsContent(
isTablet = true,
settings = mdbListSettings,
)
}
}
}

View file

@ -26,6 +26,15 @@ internal actual object PlatformLocalAccountDataCleaner {
"subtitle_bottom_offset",
"stream_reuse_last_link_enabled",
"stream_reuse_last_link_cache_hours",
"mdblist_enabled",
"mdblist_api_key",
"mdblist_use_imdb",
"mdblist_use_tmdb",
"mdblist_use_tomatoes",
"mdblist_use_metacritic",
"mdblist_use_trakt",
"mdblist_use_letterboxd",
"mdblist_use_audience",
)
actual fun wipe() {

View file

@ -1,15 +1,18 @@
package com.nuvio.app.features.addons
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSData
import platform.Foundation.NSURL
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.isSuccess
import platform.Foundation.NSUserDefaults
import platform.Foundation.dataWithContentsOfURL
import platform.posix.memcpy
actual object AddonStorage {
private const val addonUrlsKey = "installed_manifest_urls"
@ -31,26 +34,45 @@ actual object AddonStorage {
}
}
private val addonHttpClient = HttpClient(Darwin) {
install(HttpTimeout) {
requestTimeoutMillis = 10_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 10_000
}
expectSuccess = false
}
actual suspend fun httpGetText(url: String): String =
withContext(Dispatchers.Default) {
val nsUrl = NSURL(string = url)
val data = NSData.dataWithContentsOfURL(nsUrl)
?: throw IllegalStateException("Request failed")
val payload = data.toByteArray().decodeToString()
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
addonHttpClient
.get(url) {
accept(ContentType.Application.Json)
}
.let { response ->
val payload = response.bodyAsText()
if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}")
}
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
}
payload
}
payload
}
@OptIn(ExperimentalForeignApi::class)
private fun NSData.toByteArray(): ByteArray =
ByteArray(length.toInt()).apply {
if (isEmpty()) return@apply
usePinned { pinned ->
memcpy(pinned.addressOf(0), bytes, length)
actual suspend fun httpPostJson(url: String, body: String): String =
addonHttpClient
.post(url) {
accept(ContentType.Application.Json)
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(body)
}
.let { response ->
val payload = response.bodyAsText()
if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}")
}
if (payload.isBlank()) {
throw IllegalStateException("Empty response body")
}
payload
}
}

View file

@ -0,0 +1,85 @@
package com.nuvio.app.features.mdblist
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
actual object MdbListSettingsStorage {
private const val enabledKey = "mdblist_enabled"
private const val apiKey = "mdblist_api_key"
private const val useImdbKey = "mdblist_use_imdb"
private const val useTmdbKey = "mdblist_use_tmdb"
private const val useTomatoesKey = "mdblist_use_tomatoes"
private const val useMetacriticKey = "mdblist_use_metacritic"
private const val useTraktKey = "mdblist_use_trakt"
private const val useLetterboxdKey = "mdblist_use_letterboxd"
private const val useAudienceKey = "mdblist_use_audience"
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
actual fun saveEnabled(enabled: Boolean) {
saveBoolean(enabledKey, enabled)
}
actual fun loadApiKey(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(apiKey))
actual fun saveApiKey(apiKey: String) {
NSUserDefaults.standardUserDefaults.setObject(apiKey, forKey = ProfileScopedKey.of(this.apiKey))
}
actual fun loadUseImdb(): Boolean? = loadBoolean(useImdbKey)
actual fun saveUseImdb(enabled: Boolean) {
saveBoolean(useImdbKey, enabled)
}
actual fun loadUseTmdb(): Boolean? = loadBoolean(useTmdbKey)
actual fun saveUseTmdb(enabled: Boolean) {
saveBoolean(useTmdbKey, enabled)
}
actual fun loadUseTomatoes(): Boolean? = loadBoolean(useTomatoesKey)
actual fun saveUseTomatoes(enabled: Boolean) {
saveBoolean(useTomatoesKey, enabled)
}
actual fun loadUseMetacritic(): Boolean? = loadBoolean(useMetacriticKey)
actual fun saveUseMetacritic(enabled: Boolean) {
saveBoolean(useMetacriticKey, enabled)
}
actual fun loadUseTrakt(): Boolean? = loadBoolean(useTraktKey)
actual fun saveUseTrakt(enabled: Boolean) {
saveBoolean(useTraktKey, enabled)
}
actual fun loadUseLetterboxd(): Boolean? = loadBoolean(useLetterboxdKey)
actual fun saveUseLetterboxd(enabled: Boolean) {
saveBoolean(useLetterboxdKey, enabled)
}
actual fun loadUseAudience(): Boolean? = loadBoolean(useAudienceKey)
actual fun saveUseAudience(enabled: Boolean) {
saveBoolean(useAudienceKey, enabled)
}
private fun loadBoolean(key: String): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val scopedKey = ProfileScopedKey.of(key)
return if (defaults.objectForKey(scopedKey) != null) {
defaults.boolForKey(scopedKey)
} else {
null
}
}
private fun saveBoolean(key: String, enabled: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key))
}
}