mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-29 04:13:03 +00:00
tmdb init
This commit is contained in:
parent
d00b4ae2e1
commit
ca2be5fdb2
19 changed files with 1790 additions and 22 deletions
|
|
@ -33,6 +33,18 @@ generatedDir.resolve("com/nuvio/app/core/network").apply {
|
||||||
""".trimMargin()
|
""".trimMargin()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
generatedDir.resolve("com/nuvio/app/features/tmdb").apply {
|
||||||
|
mkdirs()
|
||||||
|
resolve("TmdbConfig.kt").writeText(
|
||||||
|
"""
|
||||||
|
|package com.nuvio.app.features.tmdb
|
||||||
|
|
|
||||||
|
|object TmdbConfig {
|
||||||
|
| const val API_KEY = "${supabaseProps.getProperty("TMDB_API_KEY", "")}"
|
||||||
|
|}
|
||||||
|
""".trimMargin()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
androidTarget {
|
androidTarget {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsStorage
|
||||||
import com.nuvio.app.features.player.PlayerSettingsStorage
|
import com.nuvio.app.features.player.PlayerSettingsStorage
|
||||||
import com.nuvio.app.features.profiles.ProfileStorage
|
import com.nuvio.app.features.profiles.ProfileStorage
|
||||||
import com.nuvio.app.features.settings.ThemeSettingsStorage
|
import com.nuvio.app.features.settings.ThemeSettingsStorage
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
||||||
import com.nuvio.app.features.watched.WatchedStorage
|
import com.nuvio.app.features.watched.WatchedStorage
|
||||||
import com.nuvio.app.features.streams.StreamLinkCacheStorage
|
import com.nuvio.app.features.streams.StreamLinkCacheStorage
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
||||||
|
|
@ -37,6 +38,7 @@ class MainActivity : ComponentActivity() {
|
||||||
PlayerSettingsStorage.initialize(applicationContext)
|
PlayerSettingsStorage.initialize(applicationContext)
|
||||||
ProfileStorage.initialize(applicationContext)
|
ProfileStorage.initialize(applicationContext)
|
||||||
ThemeSettingsStorage.initialize(applicationContext)
|
ThemeSettingsStorage.initialize(applicationContext)
|
||||||
|
TmdbSettingsStorage.initialize(applicationContext)
|
||||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||||
WatchProgressStorage.initialize(applicationContext)
|
WatchProgressStorage.initialize(applicationContext)
|
||||||
StreamLinkCacheStorage.initialize(applicationContext)
|
StreamLinkCacheStorage.initialize(applicationContext)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
package com.nuvio.app.features.tmdb
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||||
|
|
||||||
|
actual object TmdbSettingsStorage {
|
||||||
|
private const val preferencesName = "nuvio_tmdb_settings"
|
||||||
|
private const val enabledKey = "tmdb_enabled"
|
||||||
|
private const val languageKey = "tmdb_language"
|
||||||
|
private const val useArtworkKey = "tmdb_use_artwork"
|
||||||
|
private const val useBasicInfoKey = "tmdb_use_basic_info"
|
||||||
|
private const val useDetailsKey = "tmdb_use_details"
|
||||||
|
private const val useCreditsKey = "tmdb_use_credits"
|
||||||
|
private const val useProductionsKey = "tmdb_use_productions"
|
||||||
|
private const val useNetworksKey = "tmdb_use_networks"
|
||||||
|
private const val useEpisodesKey = "tmdb_use_episodes"
|
||||||
|
private const val useMoreLikeThisKey = "tmdb_use_more_like_this"
|
||||||
|
private const val useCollectionsKey = "tmdb_use_collections"
|
||||||
|
|
||||||
|
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 loadLanguage(): String? =
|
||||||
|
preferences?.getString(ProfileScopedKey.of(languageKey), null)
|
||||||
|
|
||||||
|
actual fun saveLanguage(language: String) {
|
||||||
|
preferences
|
||||||
|
?.edit()
|
||||||
|
?.putString(ProfileScopedKey.of(languageKey), language)
|
||||||
|
?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseArtwork(): Boolean? = loadBoolean(useArtworkKey)
|
||||||
|
|
||||||
|
actual fun saveUseArtwork(enabled: Boolean) {
|
||||||
|
saveBoolean(useArtworkKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseBasicInfo(): Boolean? = loadBoolean(useBasicInfoKey)
|
||||||
|
|
||||||
|
actual fun saveUseBasicInfo(enabled: Boolean) {
|
||||||
|
saveBoolean(useBasicInfoKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseDetails(): Boolean? = loadBoolean(useDetailsKey)
|
||||||
|
|
||||||
|
actual fun saveUseDetails(enabled: Boolean) {
|
||||||
|
saveBoolean(useDetailsKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey)
|
||||||
|
|
||||||
|
actual fun saveUseCredits(enabled: Boolean) {
|
||||||
|
saveBoolean(useCreditsKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseProductions(): Boolean? = loadBoolean(useProductionsKey)
|
||||||
|
|
||||||
|
actual fun saveUseProductions(enabled: Boolean) {
|
||||||
|
saveBoolean(useProductionsKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseNetworks(): Boolean? = loadBoolean(useNetworksKey)
|
||||||
|
|
||||||
|
actual fun saveUseNetworks(enabled: Boolean) {
|
||||||
|
saveBoolean(useNetworksKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseEpisodes(): Boolean? = loadBoolean(useEpisodesKey)
|
||||||
|
|
||||||
|
actual fun saveUseEpisodes(enabled: Boolean) {
|
||||||
|
saveBoolean(useEpisodesKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseMoreLikeThis(): Boolean? = loadBoolean(useMoreLikeThisKey)
|
||||||
|
|
||||||
|
actual fun saveUseMoreLikeThis(enabled: Boolean) {
|
||||||
|
saveBoolean(useMoreLikeThisKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseCollections(): Boolean? = loadBoolean(useCollectionsKey)
|
||||||
|
|
||||||
|
actual fun saveUseCollections(enabled: Boolean) {
|
||||||
|
saveBoolean(useCollectionsKey, 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,10 +13,14 @@ data class MetaDetails(
|
||||||
val releaseInfo: String? = null,
|
val releaseInfo: String? = null,
|
||||||
val status: String? = null,
|
val status: String? = null,
|
||||||
val imdbRating: String? = null,
|
val imdbRating: String? = null,
|
||||||
|
val ageRating: String? = null,
|
||||||
val runtime: String? = null,
|
val runtime: String? = null,
|
||||||
val genres: List<String> = emptyList(),
|
val genres: List<String> = emptyList(),
|
||||||
val director: List<String> = emptyList(),
|
val director: List<String> = emptyList(),
|
||||||
|
val writer: List<String> = emptyList(),
|
||||||
val cast: List<MetaPerson> = emptyList(),
|
val cast: List<MetaPerson> = emptyList(),
|
||||||
|
val productionCompanies: List<MetaCompany> = emptyList(),
|
||||||
|
val networks: List<MetaCompany> = emptyList(),
|
||||||
val country: String? = null,
|
val country: String? = null,
|
||||||
val awards: String? = null,
|
val awards: String? = null,
|
||||||
val language: String? = null,
|
val language: String? = null,
|
||||||
|
|
@ -32,6 +36,12 @@ data class MetaPerson(
|
||||||
val photo: String? = null,
|
val photo: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class MetaCompany(
|
||||||
|
val name: String,
|
||||||
|
val logo: String? = null,
|
||||||
|
val tmdbId: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class MetaLink(
|
data class MetaLink(
|
||||||
val name: String,
|
val name: String,
|
||||||
val category: String,
|
val category: String,
|
||||||
|
|
@ -46,6 +56,7 @@ data class MetaVideo(
|
||||||
val season: Int? = null,
|
val season: Int? = null,
|
||||||
val episode: Int? = null,
|
val episode: Int? = null,
|
||||||
val overview: String? = null,
|
val overview: String? = null,
|
||||||
|
val runtime: Int? = null,
|
||||||
val streams: List<StreamItem> = emptyList(),
|
val streams: List<StreamItem> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,11 @@ internal object MetaDetailsParser {
|
||||||
releaseInfo = meta.string("releaseInfo"),
|
releaseInfo = meta.string("releaseInfo"),
|
||||||
status = meta.string("status"),
|
status = meta.string("status"),
|
||||||
imdbRating = meta.string("imdbRating"),
|
imdbRating = meta.string("imdbRating"),
|
||||||
|
ageRating = meta.string("ageRating"),
|
||||||
runtime = meta.string("runtime"),
|
runtime = meta.string("runtime"),
|
||||||
genres = meta.stringList("genres"),
|
genres = meta.stringList("genres"),
|
||||||
director = meta.directors(links),
|
director = meta.directors(links),
|
||||||
|
writer = meta.writers(links),
|
||||||
cast = meta.cast(links),
|
cast = meta.cast(links),
|
||||||
country = meta.string("country"),
|
country = meta.string("country"),
|
||||||
awards = meta.string("awards"),
|
awards = meta.string("awards"),
|
||||||
|
|
@ -137,6 +139,22 @@ internal object MetaDetailsParser {
|
||||||
return mergePeople(appExtraCast, topLevelCast, linkedCast)
|
return mergePeople(appExtraCast, topLevelCast, linkedCast)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.writers(links: List<MetaLink>): List<String> {
|
||||||
|
val appExtras = this["app_extras"] as? JsonObject
|
||||||
|
val topLevel = stringListOrCsv("writer")
|
||||||
|
val extraWriters = appExtras.personNameList("writers")
|
||||||
|
val linkWriters = links.filter { link ->
|
||||||
|
link.category.equals("writer", ignoreCase = true) ||
|
||||||
|
link.category.equals("writers", ignoreCase = true) ||
|
||||||
|
link.category.equals("screenplay", ignoreCase = true)
|
||||||
|
}.map(MetaLink::name)
|
||||||
|
|
||||||
|
return (topLevel + extraWriters + linkWriters)
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotBlank)
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
private fun JsonObject?.personNameList(name: String): List<String> =
|
private fun JsonObject?.personNameList(name: String): List<String> =
|
||||||
people(name).map(MetaPerson::name)
|
people(name).map(MetaPerson::name)
|
||||||
|
|
||||||
|
|
@ -206,6 +224,7 @@ internal object MetaDetailsParser {
|
||||||
season = video.int("season"),
|
season = video.int("season"),
|
||||||
episode = video.int("episode"),
|
episode = video.int("episode"),
|
||||||
overview = video.string("overview") ?: video.string("description"),
|
overview = video.string("overview") ?: video.string("description"),
|
||||||
|
runtime = video.int("runtime"),
|
||||||
streams = video.embeddedStreams(),
|
streams = video.embeddedStreams(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.addons.AddonManifest
|
import com.nuvio.app.features.addons.AddonManifest
|
||||||
import com.nuvio.app.features.addons.AddonRepository
|
import com.nuvio.app.features.addons.AddonRepository
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbMetadataService
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
@ -117,6 +119,7 @@ object MetaDetailsRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val FETCH_TIMEOUT_MS = 5_000L
|
private const val FETCH_TIMEOUT_MS = 5_000L
|
||||||
|
private const val TMDB_ENRICH_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
private suspend fun tryFetchMeta(
|
private suspend fun tryFetchMeta(
|
||||||
manifest: AddonManifest,
|
manifest: AddonManifest,
|
||||||
|
|
@ -124,6 +127,7 @@ object MetaDetailsRepository {
|
||||||
id: String,
|
id: String,
|
||||||
): MetaDetails? {
|
): MetaDetails? {
|
||||||
return try {
|
return try {
|
||||||
|
TmdbSettingsRepository.ensureLoaded()
|
||||||
val baseUrl = manifest.transportUrl
|
val baseUrl = manifest.transportUrl
|
||||||
.substringBefore("?")
|
.substringBefore("?")
|
||||||
.removeSuffix("/manifest.json")
|
.removeSuffix("/manifest.json")
|
||||||
|
|
@ -132,12 +136,19 @@ object MetaDetailsRepository {
|
||||||
val payload = httpGetText(url)
|
val payload = httpGetText(url)
|
||||||
log.d { "Raw payload length=${payload.length}, first 500 chars: ${payload.take(500)}" }
|
log.d { "Raw payload length=${payload.length}, first 500 chars: ${payload.take(500)}" }
|
||||||
val result = MetaDetailsParser.parse(payload)
|
val result = MetaDetailsParser.parse(payload)
|
||||||
log.d { "Parsed meta: type=${result.type}, name=${result.name}, videos=${result.videos.size}" }
|
val enriched = withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) {
|
||||||
if (result.videos.isNotEmpty()) {
|
TmdbMetadataService.enrichMeta(
|
||||||
val first = result.videos.first()
|
meta = result,
|
||||||
|
fallbackItemId = id,
|
||||||
|
settings = TmdbSettingsRepository.snapshot(),
|
||||||
|
)
|
||||||
|
} ?: result
|
||||||
|
log.d { "Parsed meta: type=${enriched.type}, name=${enriched.name}, videos=${enriched.videos.size}" }
|
||||||
|
if (enriched.videos.isNotEmpty()) {
|
||||||
|
val first = enriched.videos.first()
|
||||||
log.d { "First video: id=${first.id} title=${first.title} s=${first.season} e=${first.episode} embeddedStreams=${first.streams.size}" }
|
log.d { "First video: id=${first.id} title=${first.title} s=${first.season} e=${first.episode} embeddedStreams=${first.streams.size}" }
|
||||||
}
|
}
|
||||||
result
|
enriched
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
log.e(e) { "Failed to fetch/parse meta from ${manifest.transportUrl}" }
|
log.e(e) { "Failed to fetch/parse meta from ${manifest.transportUrl}" }
|
||||||
null
|
null
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ fun DetailMetaInfo(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
// Year, Runtime, IMDb rating row
|
|
||||||
val infoParts = buildList {
|
val infoParts = buildList {
|
||||||
meta.releaseInfo?.let { add(it) }
|
meta.releaseInfo?.let { add(it) }
|
||||||
|
meta.ageRating?.let { add(it) }
|
||||||
meta.runtime?.let { add(it.uppercase()) }
|
meta.runtime?.let { add(it.uppercase()) }
|
||||||
}
|
}
|
||||||
if (infoParts.isNotEmpty() || meta.imdbRating != null) {
|
if (infoParts.isNotEmpty() || meta.imdbRating != null) {
|
||||||
|
|
@ -88,24 +88,50 @@ fun DetailMetaInfo(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Director
|
val detailChips = buildList {
|
||||||
if (meta.director.isNotEmpty()) {
|
meta.status?.let { add(it) }
|
||||||
Row {
|
meta.country?.let { add(it) }
|
||||||
Text(
|
meta.language?.let { add(it.uppercase()) }
|
||||||
text = "Director: ",
|
}
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
if (detailChips.isNotEmpty()) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
FlowRow(
|
||||||
fontWeight = FontWeight.SemiBold,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
)
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
Text(
|
) {
|
||||||
text = meta.director.joinToString(", "),
|
detailChips.forEach { chip ->
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
DetailChip(label = chip)
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description
|
if (meta.director.isNotEmpty()) {
|
||||||
|
MetaLabelValueRow(
|
||||||
|
label = "Director",
|
||||||
|
value = meta.director.joinToString(", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.writer.isNotEmpty()) {
|
||||||
|
MetaLabelValueRow(
|
||||||
|
label = "Writer",
|
||||||
|
value = meta.writer.joinToString(", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.productionCompanies.isNotEmpty()) {
|
||||||
|
MetaLabelValueRow(
|
||||||
|
label = "Production",
|
||||||
|
value = meta.productionCompanies.joinToString(", ") { it.name },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.networks.isNotEmpty()) {
|
||||||
|
MetaLabelValueRow(
|
||||||
|
label = "Network",
|
||||||
|
value = meta.networks.joinToString(", ") { it.name },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!meta.description.isNullOrBlank()) {
|
if (!meta.description.isNullOrBlank()) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
Column {
|
Column {
|
||||||
|
|
@ -129,5 +155,41 @@ fun DetailMetaInfo(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetaLabelValueRow(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
) {
|
||||||
|
Row {
|
||||||
|
Text(
|
||||||
|
text = "$label: ",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DetailChip(label: String) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val ImdbYellow = Color(0xFFF5C518)
|
private val ImdbYellow = Color(0xFFF5C518)
|
||||||
private val ImdbBlack = Color(0xFF000000)
|
private val ImdbBlack = Color(0xFF000000)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||||
import com.nuvio.app.features.library.LibraryRepository
|
import com.nuvio.app.features.library.LibraryRepository
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
import com.nuvio.app.features.watched.WatchedRepository
|
import com.nuvio.app.features.watched.WatchedRepository
|
||||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||||
|
|
@ -122,6 +123,7 @@ object ProfileRepository {
|
||||||
PlayerSettingsRepository.onProfileChanged()
|
PlayerSettingsRepository.onProfileChanged()
|
||||||
HomeCatalogSettingsRepository.onProfileChanged()
|
HomeCatalogSettingsRepository.onProfileChanged()
|
||||||
ContinueWatchingPreferencesRepository.onProfileChanged()
|
ContinueWatchingPreferencesRepository.onProfileChanged()
|
||||||
|
TmdbSettingsRepository.onProfileChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun pushProfiles(profiles: List<ProfilePushPayload>) {
|
suspend fun pushProfiles(profiles: List<ProfilePushPayload>) {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ package com.nuvio.app.features.settings
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Extension
|
import androidx.compose.material.icons.rounded.Extension
|
||||||
|
import androidx.compose.material.icons.rounded.MovieFilter
|
||||||
import androidx.compose.material.icons.rounded.Tune
|
import androidx.compose.material.icons.rounded.Tune
|
||||||
|
|
||||||
internal fun LazyListScope.contentDiscoveryContent(
|
internal fun LazyListScope.contentDiscoveryContent(
|
||||||
isTablet: Boolean,
|
isTablet: Boolean,
|
||||||
onAddonsClick: () -> Unit,
|
onAddonsClick: () -> Unit,
|
||||||
onHomescreenClick: () -> Unit,
|
onHomescreenClick: () -> Unit,
|
||||||
|
onTmdbClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
|
|
@ -26,6 +28,22 @@ internal fun LazyListScope.contentDiscoveryContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
SettingsSection(
|
||||||
|
title = "ENRICHMENT",
|
||||||
|
isTablet = isTablet,
|
||||||
|
) {
|
||||||
|
SettingsGroup(isTablet = isTablet) {
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = "TMDB Enrichment",
|
||||||
|
description = "Enhance detail pages with TMDB artwork, credits, episode metadata, and more.",
|
||||||
|
icon = Icons.Rounded.MovieFilter,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onClick = onTmdbClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title = "HOME",
|
title = "HOME",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package com.nuvio.app.features.settings
|
package com.nuvio.app.features.settings
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Palette
|
|
||||||
import androidx.compose.material.icons.rounded.Style
|
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
|
|
@ -20,6 +18,7 @@ internal enum class SettingsPage(
|
||||||
Playback("Playback"),
|
Playback("Playback"),
|
||||||
Appearance("Appearance"),
|
Appearance("Appearance"),
|
||||||
ContentDiscovery("Content & Discovery"),
|
ContentDiscovery("Content & Discovery"),
|
||||||
|
TmdbEnrichment("TMDB Enrichment"),
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun SettingsPage.previousPage(): SettingsPage? =
|
internal fun SettingsPage.previousPage(): SettingsPage? =
|
||||||
|
|
@ -28,4 +27,5 @@ internal fun SettingsPage.previousPage(): SettingsPage? =
|
||||||
SettingsPage.Playback -> SettingsPage.Root
|
SettingsPage.Playback -> SettingsPage.Root
|
||||||
SettingsPage.Appearance -> SettingsPage.Root
|
SettingsPage.Appearance -> SettingsPage.Root
|
||||||
SettingsPage.ContentDiscovery -> SettingsPage.Root
|
SettingsPage.ContentDiscovery -> SettingsPage.Root
|
||||||
|
SettingsPage.TmdbEnrichment -> SettingsPage.ContentDiscovery
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ import com.nuvio.app.core.ui.PlatformBackHandler
|
||||||
import com.nuvio.app.core.ui.NuvioScreen
|
import com.nuvio.app.core.ui.NuvioScreen
|
||||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbSettings
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
|
|
@ -62,6 +64,10 @@ fun SettingsScreen(
|
||||||
ThemeSettingsRepository.selectedTheme
|
ThemeSettingsRepository.selectedTheme
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
|
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
|
||||||
|
val tmdbSettings by remember {
|
||||||
|
TmdbSettingsRepository.ensureLoaded()
|
||||||
|
TmdbSettingsRepository.uiState
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
|
var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) }
|
||||||
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
|
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
|
||||||
|
|
@ -90,6 +96,7 @@ fun SettingsScreen(
|
||||||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||||
amoledEnabled = amoledEnabled,
|
amoledEnabled = amoledEnabled,
|
||||||
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
||||||
|
tmdbSettings = tmdbSettings,
|
||||||
onSwitchProfile = onSwitchProfile,
|
onSwitchProfile = onSwitchProfile,
|
||||||
onHomescreenClick = onHomescreenClick,
|
onHomescreenClick = onHomescreenClick,
|
||||||
onContinueWatchingClick = onContinueWatchingClick,
|
onContinueWatchingClick = onContinueWatchingClick,
|
||||||
|
|
@ -114,6 +121,7 @@ fun SettingsScreen(
|
||||||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||||
amoledEnabled = amoledEnabled,
|
amoledEnabled = amoledEnabled,
|
||||||
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
||||||
|
tmdbSettings = tmdbSettings,
|
||||||
onSwitchProfile = onSwitchProfile,
|
onSwitchProfile = onSwitchProfile,
|
||||||
onHomescreenClick = onHomescreenClick,
|
onHomescreenClick = onHomescreenClick,
|
||||||
onContinueWatchingClick = onContinueWatchingClick,
|
onContinueWatchingClick = onContinueWatchingClick,
|
||||||
|
|
@ -142,6 +150,7 @@ private fun MobileSettingsScreen(
|
||||||
onThemeSelected: (AppTheme) -> Unit,
|
onThemeSelected: (AppTheme) -> Unit,
|
||||||
amoledEnabled: Boolean,
|
amoledEnabled: Boolean,
|
||||||
onAmoledToggle: (Boolean) -> Unit,
|
onAmoledToggle: (Boolean) -> Unit,
|
||||||
|
tmdbSettings: TmdbSettings,
|
||||||
onSwitchProfile: (() -> Unit)? = null,
|
onSwitchProfile: (() -> Unit)? = null,
|
||||||
onHomescreenClick: () -> Unit = {},
|
onHomescreenClick: () -> Unit = {},
|
||||||
onContinueWatchingClick: () -> Unit = {},
|
onContinueWatchingClick: () -> Unit = {},
|
||||||
|
|
@ -191,6 +200,11 @@ private fun MobileSettingsScreen(
|
||||||
isTablet = false,
|
isTablet = false,
|
||||||
onAddonsClick = onAddonsClick,
|
onAddonsClick = onAddonsClick,
|
||||||
onHomescreenClick = onHomescreenClick,
|
onHomescreenClick = onHomescreenClick,
|
||||||
|
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
||||||
|
)
|
||||||
|
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
||||||
|
isTablet = false,
|
||||||
|
settings = tmdbSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -214,6 +228,7 @@ private fun TabletSettingsScreen(
|
||||||
onThemeSelected: (AppTheme) -> Unit,
|
onThemeSelected: (AppTheme) -> Unit,
|
||||||
amoledEnabled: Boolean,
|
amoledEnabled: Boolean,
|
||||||
onAmoledToggle: (Boolean) -> Unit,
|
onAmoledToggle: (Boolean) -> Unit,
|
||||||
|
tmdbSettings: TmdbSettings,
|
||||||
onSwitchProfile: (() -> Unit)? = null,
|
onSwitchProfile: (() -> Unit)? = null,
|
||||||
onHomescreenClick: () -> Unit = {},
|
onHomescreenClick: () -> Unit = {},
|
||||||
onContinueWatchingClick: () -> Unit = {},
|
onContinueWatchingClick: () -> Unit = {},
|
||||||
|
|
@ -312,6 +327,11 @@ private fun TabletSettingsScreen(
|
||||||
isTablet = true,
|
isTablet = true,
|
||||||
onAddonsClick = onAddonsClick,
|
onAddonsClick = onAddonsClick,
|
||||||
onHomescreenClick = onHomescreenClick,
|
onHomescreenClick = onHomescreenClick,
|
||||||
|
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
||||||
|
)
|
||||||
|
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
||||||
|
isTablet = true,
|
||||||
|
settings = tmdbSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
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.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.unit.dp
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbSettings
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||||
|
import com.nuvio.app.features.tmdb.normalizeLanguage
|
||||||
|
|
||||||
|
internal fun LazyListScope.tmdbSettingsContent(
|
||||||
|
isTablet: Boolean,
|
||||||
|
settings: TmdbSettings,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SettingsSection(
|
||||||
|
title = "TMDB",
|
||||||
|
isTablet = isTablet,
|
||||||
|
) {
|
||||||
|
SettingsGroup(isTablet = isTablet) {
|
||||||
|
SettingsSwitchRow(
|
||||||
|
title = "Enable TMDB enrichment",
|
||||||
|
description = "Use TMDB to enrich addon metadata on the details screen when a TMDB or IMDb ID is available.",
|
||||||
|
checked = settings.enabled,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onCheckedChange = TmdbSettingsRepository::setEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SettingsSection(
|
||||||
|
title = "LOCALIZATION",
|
||||||
|
isTablet = isTablet,
|
||||||
|
) {
|
||||||
|
SettingsGroup(isTablet = isTablet) {
|
||||||
|
TmdbLanguageRow(
|
||||||
|
isTablet = isTablet,
|
||||||
|
value = settings.language,
|
||||||
|
enabled = settings.enabled,
|
||||||
|
onLanguageCommitted = TmdbSettingsRepository::setLanguage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
SettingsSection(
|
||||||
|
title = "MODULES",
|
||||||
|
isTablet = isTablet,
|
||||||
|
) {
|
||||||
|
SettingsGroup(isTablet = isTablet) {
|
||||||
|
TmdbToggleRow(
|
||||||
|
isTablet = isTablet,
|
||||||
|
title = "Artwork",
|
||||||
|
description = "Replace backdrop, poster, and logo with TMDB artwork.",
|
||||||
|
checked = settings.useArtwork,
|
||||||
|
enabled = settings.enabled,
|
||||||
|
onCheckedChange = TmdbSettingsRepository::setUseArtwork,
|
||||||
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
TmdbToggleRow(
|
||||||
|
isTablet = isTablet,
|
||||||
|
title = "Basic info",
|
||||||
|
description = "Use TMDB title, synopsis, genres, and rating.",
|
||||||
|
checked = settings.useBasicInfo,
|
||||||
|
enabled = settings.enabled,
|
||||||
|
onCheckedChange = TmdbSettingsRepository::setUseBasicInfo,
|
||||||
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
TmdbToggleRow(
|
||||||
|
isTablet = isTablet,
|
||||||
|
title = "Details",
|
||||||
|
description = "Use TMDB release info, runtime, age rating, status, country, and language.",
|
||||||
|
checked = settings.useDetails,
|
||||||
|
enabled = settings.enabled,
|
||||||
|
onCheckedChange = TmdbSettingsRepository::setUseDetails,
|
||||||
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
TmdbToggleRow(
|
||||||
|
isTablet = isTablet,
|
||||||
|
title = "Credits",
|
||||||
|
description = "Use TMDB creators, directors, writers, and cast photos.",
|
||||||
|
checked = settings.useCredits,
|
||||||
|
enabled = settings.enabled,
|
||||||
|
onCheckedChange = TmdbSettingsRepository::setUseCredits,
|
||||||
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
TmdbToggleRow(
|
||||||
|
isTablet = isTablet,
|
||||||
|
title = "Production companies",
|
||||||
|
description = "Use TMDB production company metadata on the details screen.",
|
||||||
|
checked = settings.useProductions,
|
||||||
|
enabled = settings.enabled,
|
||||||
|
onCheckedChange = TmdbSettingsRepository::setUseProductions,
|
||||||
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
TmdbToggleRow(
|
||||||
|
isTablet = isTablet,
|
||||||
|
title = "Networks",
|
||||||
|
description = "Use TMDB network metadata for TV titles.",
|
||||||
|
checked = settings.useNetworks,
|
||||||
|
enabled = settings.enabled,
|
||||||
|
onCheckedChange = TmdbSettingsRepository::setUseNetworks,
|
||||||
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
TmdbToggleRow(
|
||||||
|
isTablet = isTablet,
|
||||||
|
title = "Episodes",
|
||||||
|
description = "Use TMDB episode titles, thumbnails, descriptions, and runtimes for series.",
|
||||||
|
checked = settings.useEpisodes,
|
||||||
|
enabled = settings.enabled,
|
||||||
|
onCheckedChange = TmdbSettingsRepository::setUseEpisodes,
|
||||||
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
TmdbToggleRow(
|
||||||
|
isTablet = isTablet,
|
||||||
|
title = "More like this",
|
||||||
|
description = "Reserved for the upcoming TMDB recommendation rail port.",
|
||||||
|
checked = settings.useMoreLikeThis,
|
||||||
|
enabled = settings.enabled,
|
||||||
|
onCheckedChange = TmdbSettingsRepository::setUseMoreLikeThis,
|
||||||
|
)
|
||||||
|
SettingsGroupDivider(isTablet = isTablet)
|
||||||
|
TmdbToggleRow(
|
||||||
|
isTablet = isTablet,
|
||||||
|
title = "Collections",
|
||||||
|
description = "Reserved for the upcoming TMDB collection rail port.",
|
||||||
|
checked = settings.useCollections,
|
||||||
|
enabled = settings.enabled,
|
||||||
|
onCheckedChange = TmdbSettingsRepository::setUseCollections,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TmdbLanguageRow(
|
||||||
|
isTablet: Boolean,
|
||||||
|
value: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onLanguageCommitted: (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 = "Preferred language",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Set the TMDB language code used for localized metadata, for example `en`, `en-US`, or `pt-BR`.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = {
|
||||||
|
draft = it
|
||||||
|
if (enabled) {
|
||||||
|
onLanguageCommitted(normalizeLanguage(it))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text("Language code") },
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TmdbToggleRow(
|
||||||
|
isTablet: Boolean,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
checked: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
SettingsSwitchRow(
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
|
isTablet = isTablet,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,688 @@
|
||||||
|
package com.nuvio.app.features.tmdb
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import com.nuvio.app.features.details.MetaCompany
|
||||||
|
import com.nuvio.app.features.details.MetaDetails
|
||||||
|
import com.nuvio.app.features.details.MetaPerson
|
||||||
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
object TmdbMetadataService {
|
||||||
|
private val log = Logger.withTag("TmdbMetadata")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val enrichmentCache = mutableMapOf<String, TmdbEnrichment>()
|
||||||
|
private val episodeCache = mutableMapOf<String, Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
|
||||||
|
|
||||||
|
suspend fun enrichMeta(
|
||||||
|
meta: MetaDetails,
|
||||||
|
fallbackItemId: String,
|
||||||
|
settings: TmdbSettings,
|
||||||
|
): MetaDetails {
|
||||||
|
if (!settings.enabled || TmdbConfig.API_KEY.isBlank()) return meta
|
||||||
|
|
||||||
|
val tmdbType = normalizeMetaType(meta.type)
|
||||||
|
val tmdbId = TmdbService.ensureTmdbId(meta.id, tmdbType)
|
||||||
|
?: TmdbService.ensureTmdbId(fallbackItemId, tmdbType)
|
||||||
|
?: return meta
|
||||||
|
|
||||||
|
val needsEpisodes = settings.useEpisodes && tmdbType == "tv"
|
||||||
|
val (enrichment, episodeMap) = coroutineScope {
|
||||||
|
val enrichmentDeferred = async {
|
||||||
|
fetchEnrichment(
|
||||||
|
tmdbId = tmdbId,
|
||||||
|
mediaType = tmdbType,
|
||||||
|
language = settings.language,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val episodeDeferred = if (needsEpisodes) {
|
||||||
|
async {
|
||||||
|
val seasons = meta.videos.mapNotNull { it.season }.distinct()
|
||||||
|
fetchEpisodeEnrichment(
|
||||||
|
tmdbId = tmdbId,
|
||||||
|
seasonNumbers = seasons,
|
||||||
|
language = settings.language,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
enrichmentDeferred.await() to episodeDeferred?.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyEnrichment(
|
||||||
|
meta = meta,
|
||||||
|
enrichment = enrichment,
|
||||||
|
episodeMap = episodeMap.orEmpty(),
|
||||||
|
settings = settings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun applyEnrichment(
|
||||||
|
meta: MetaDetails,
|
||||||
|
enrichment: TmdbEnrichment?,
|
||||||
|
episodeMap: Map<Pair<Int, Int>, TmdbEpisodeEnrichment>,
|
||||||
|
settings: TmdbSettings,
|
||||||
|
): MetaDetails {
|
||||||
|
if (enrichment == null && episodeMap.isEmpty()) return meta
|
||||||
|
|
||||||
|
var updated = meta
|
||||||
|
|
||||||
|
if (enrichment != null && settings.useArtwork) {
|
||||||
|
updated = updated.copy(
|
||||||
|
background = enrichment.backdrop ?: updated.background,
|
||||||
|
poster = enrichment.poster ?: updated.poster,
|
||||||
|
logo = enrichment.logo ?: updated.logo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enrichment != null && settings.useBasicInfo) {
|
||||||
|
updated = updated.copy(
|
||||||
|
name = enrichment.localizedTitle ?: updated.name,
|
||||||
|
description = enrichment.description ?: updated.description,
|
||||||
|
imdbRating = enrichment.rating?.formatRating() ?: updated.imdbRating,
|
||||||
|
genres = enrichment.genres.ifEmpty { updated.genres },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enrichment != null && settings.useDetails) {
|
||||||
|
updated = updated.copy(
|
||||||
|
releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo,
|
||||||
|
status = enrichment.status ?: updated.status,
|
||||||
|
ageRating = enrichment.ageRating ?: updated.ageRating,
|
||||||
|
runtime = enrichment.runtimeMinutes?.formatRuntime() ?: updated.runtime,
|
||||||
|
country = enrichment.countries.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: updated.country,
|
||||||
|
language = enrichment.language ?: updated.language,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enrichment != null && settings.useCredits) {
|
||||||
|
updated = updated.copy(
|
||||||
|
director = enrichment.director.ifEmpty { updated.director },
|
||||||
|
writer = enrichment.writer.ifEmpty { updated.writer },
|
||||||
|
cast = enrichment.people.ifEmpty { updated.cast },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enrichment != null && settings.useProductions && enrichment.productionCompanies.isNotEmpty()) {
|
||||||
|
updated = updated.copy(productionCompanies = enrichment.productionCompanies)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enrichment != null && settings.useNetworks && enrichment.networks.isNotEmpty()) {
|
||||||
|
updated = updated.copy(networks = enrichment.networks)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodeMap.isNotEmpty()) {
|
||||||
|
updated = updated.copy(
|
||||||
|
videos = meta.videos.map { video ->
|
||||||
|
val key = video.season?.let { season ->
|
||||||
|
video.episode?.let { episode -> season to episode }
|
||||||
|
}
|
||||||
|
val enrichmentForEpisode = key?.let(episodeMap::get)
|
||||||
|
if (enrichmentForEpisode == null) {
|
||||||
|
video
|
||||||
|
} else {
|
||||||
|
video.copy(
|
||||||
|
title = enrichmentForEpisode.title ?: video.title,
|
||||||
|
overview = enrichmentForEpisode.overview ?: video.overview,
|
||||||
|
released = enrichmentForEpisode.airDate ?: video.released,
|
||||||
|
thumbnail = enrichmentForEpisode.thumbnail ?: video.thumbnail,
|
||||||
|
runtime = enrichmentForEpisode.runtimeMinutes ?: video.runtime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchEnrichment(
|
||||||
|
tmdbId: String,
|
||||||
|
mediaType: String,
|
||||||
|
language: String,
|
||||||
|
): TmdbEnrichment? = withContext(Dispatchers.Default) {
|
||||||
|
val normalizedLanguage = normalizeTmdbLanguage(language)
|
||||||
|
val cacheKey = "$tmdbId:$mediaType:$normalizedLanguage"
|
||||||
|
enrichmentCache[cacheKey]?.let { return@withContext it }
|
||||||
|
|
||||||
|
val numericId = tmdbId.toIntOrNull() ?: return@withContext null
|
||||||
|
val includeImageLanguage = buildString {
|
||||||
|
append(normalizedLanguage.substringBefore("-"))
|
||||||
|
append(",")
|
||||||
|
append(normalizedLanguage)
|
||||||
|
append(",en,null")
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = coroutineScope {
|
||||||
|
val details = async {
|
||||||
|
fetch<TmdbDetailsResponse>(
|
||||||
|
endpoint = "$mediaType/$numericId",
|
||||||
|
query = mapOf("language" to normalizedLanguage),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val credits = async {
|
||||||
|
fetch<TmdbCreditsResponse>(
|
||||||
|
endpoint = "$mediaType/$numericId/credits",
|
||||||
|
query = mapOf("language" to normalizedLanguage),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val images = async {
|
||||||
|
fetch<TmdbImagesResponse>(
|
||||||
|
endpoint = "$mediaType/$numericId/images",
|
||||||
|
query = mapOf("include_image_language" to includeImageLanguage),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val ageRating = async {
|
||||||
|
when (mediaType) {
|
||||||
|
"tv" -> fetch<TmdbTvContentRatingsResponse>(
|
||||||
|
endpoint = "tv/$numericId/content_ratings",
|
||||||
|
)?.results.orEmpty().selectTvAgeRating(normalizedLanguage)
|
||||||
|
else -> fetch<TmdbMovieReleaseDatesResponse>(
|
||||||
|
endpoint = "movie/$numericId/release_dates",
|
||||||
|
)?.results.orEmpty().selectMovieAgeRating(normalizedLanguage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Quadruple(
|
||||||
|
first = details.await(),
|
||||||
|
second = credits.await(),
|
||||||
|
third = images.await(),
|
||||||
|
fourth = ageRating.await(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val details = response.first ?: return@withContext null
|
||||||
|
val credits = response.second
|
||||||
|
val images = response.third
|
||||||
|
|
||||||
|
val genres = details.genres.mapNotNull { it.name?.trim()?.takeIf(String::isNotBlank) }
|
||||||
|
val description = details.overview?.trim()?.takeIf(String::isNotBlank)
|
||||||
|
val releaseInfo = details.releaseDate ?: details.firstAirDate
|
||||||
|
val localizedTitle = listOf(details.title, details.name).firstNotNullOfOrNull { it?.trim()?.takeIf(String::isNotBlank) }
|
||||||
|
val people = buildPeople(details = details, credits = credits, mediaType = mediaType)
|
||||||
|
val directors = buildDirectors(details = details, credits = credits, mediaType = mediaType)
|
||||||
|
val writers = buildWriters(credits = credits, mediaType = mediaType, hasDirectors = directors.isNotEmpty())
|
||||||
|
val enrichment = TmdbEnrichment(
|
||||||
|
localizedTitle = localizedTitle,
|
||||||
|
description = description,
|
||||||
|
genres = genres,
|
||||||
|
backdrop = buildImageUrl(details.backdropPath, "w1280"),
|
||||||
|
logo = buildImageUrl(images?.logos.orEmpty().selectBestLocalizedImagePath(normalizedLanguage), "w500"),
|
||||||
|
poster = buildImageUrl(details.posterPath, "w500"),
|
||||||
|
people = people,
|
||||||
|
director = directors,
|
||||||
|
writer = writers,
|
||||||
|
releaseInfo = releaseInfo,
|
||||||
|
rating = details.voteAverage,
|
||||||
|
runtimeMinutes = details.runtime ?: details.episodeRunTime.firstOrNull(),
|
||||||
|
ageRating = response.fourth,
|
||||||
|
status = details.status?.trim()?.takeIf(String::isNotBlank),
|
||||||
|
countries = details.productionCountries
|
||||||
|
.mapNotNull { it.iso31661?.trim()?.takeIf(String::isNotBlank) }
|
||||||
|
.ifEmpty { details.originCountry.filter(String::isNotBlank) },
|
||||||
|
language = details.originalLanguage?.trim()?.takeIf(String::isNotBlank),
|
||||||
|
productionCompanies = details.productionCompanies.mapNotNull { it.toMetaCompany() },
|
||||||
|
networks = details.networks.mapNotNull { it.toMetaCompany() },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!enrichment.hasContent()) return@withContext null
|
||||||
|
enrichmentCache[cacheKey] = enrichment
|
||||||
|
enrichment
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchEpisodeEnrichment(
|
||||||
|
tmdbId: String,
|
||||||
|
seasonNumbers: List<Int>,
|
||||||
|
language: String,
|
||||||
|
): Map<Pair<Int, Int>, TmdbEpisodeEnrichment> = withContext(Dispatchers.Default) {
|
||||||
|
val normalizedLanguage = normalizeTmdbLanguage(language)
|
||||||
|
val numericId = tmdbId.toIntOrNull() ?: return@withContext emptyMap()
|
||||||
|
val normalizedSeasons = seasonNumbers.distinct().sorted()
|
||||||
|
if (normalizedSeasons.isEmpty()) return@withContext emptyMap()
|
||||||
|
|
||||||
|
val cacheKey = "$numericId:${normalizedSeasons.joinToString(",")}:$normalizedLanguage"
|
||||||
|
episodeCache[cacheKey]?.let { return@withContext it }
|
||||||
|
|
||||||
|
val pairs = coroutineScope {
|
||||||
|
normalizedSeasons.map { season ->
|
||||||
|
async {
|
||||||
|
val details = fetch<TmdbSeasonDetailsResponse>(
|
||||||
|
endpoint = "tv/$numericId/season/$season",
|
||||||
|
query = mapOf("language" to normalizedLanguage),
|
||||||
|
) ?: return@async emptyMap()
|
||||||
|
|
||||||
|
details.episodes
|
||||||
|
.mapNotNull { episode ->
|
||||||
|
val episodeNumber = episode.episodeNumber ?: return@mapNotNull null
|
||||||
|
(season to episodeNumber) to TmdbEpisodeEnrichment(
|
||||||
|
title = episode.name?.trim()?.takeIf(String::isNotBlank),
|
||||||
|
overview = episode.overview?.trim()?.takeIf(String::isNotBlank),
|
||||||
|
thumbnail = buildImageUrl(episode.stillPath, "w500"),
|
||||||
|
airDate = episode.airDate?.trim()?.takeIf(String::isNotBlank),
|
||||||
|
runtimeMinutes = episode.runtime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
val merged = pairs.fold(emptyMap<Pair<Int, Int>, TmdbEpisodeEnrichment>()) { acc, value -> acc + value }
|
||||||
|
if (merged.isNotEmpty()) {
|
||||||
|
episodeCache[cacheKey] = merged
|
||||||
|
}
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> fetch(
|
||||||
|
endpoint: String,
|
||||||
|
query: Map<String, String> = emptyMap(),
|
||||||
|
): T? {
|
||||||
|
val url = buildTmdbUrl(endpoint = endpoint, query = query)
|
||||||
|
return runCatching {
|
||||||
|
json.decodeFromString<T>(httpGetText(url))
|
||||||
|
}.onFailure { error ->
|
||||||
|
log.w { "TMDB request failed for $endpoint: ${error.message}" }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class TmdbEnrichment(
|
||||||
|
val localizedTitle: String?,
|
||||||
|
val description: String?,
|
||||||
|
val genres: List<String>,
|
||||||
|
val backdrop: String?,
|
||||||
|
val logo: String?,
|
||||||
|
val poster: String?,
|
||||||
|
val people: List<MetaPerson>,
|
||||||
|
val director: List<String>,
|
||||||
|
val writer: List<String>,
|
||||||
|
val releaseInfo: String?,
|
||||||
|
val rating: Double?,
|
||||||
|
val runtimeMinutes: Int?,
|
||||||
|
val ageRating: String?,
|
||||||
|
val status: String?,
|
||||||
|
val countries: List<String>,
|
||||||
|
val language: String?,
|
||||||
|
val productionCompanies: List<MetaCompany>,
|
||||||
|
val networks: List<MetaCompany>,
|
||||||
|
) {
|
||||||
|
fun hasContent(): Boolean =
|
||||||
|
localizedTitle != null ||
|
||||||
|
description != null ||
|
||||||
|
genres.isNotEmpty() ||
|
||||||
|
backdrop != null ||
|
||||||
|
logo != null ||
|
||||||
|
poster != null ||
|
||||||
|
people.isNotEmpty() ||
|
||||||
|
director.isNotEmpty() ||
|
||||||
|
writer.isNotEmpty() ||
|
||||||
|
releaseInfo != null ||
|
||||||
|
rating != null ||
|
||||||
|
runtimeMinutes != null ||
|
||||||
|
ageRating != null ||
|
||||||
|
status != null ||
|
||||||
|
countries.isNotEmpty() ||
|
||||||
|
language != null ||
|
||||||
|
productionCompanies.isNotEmpty() ||
|
||||||
|
networks.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class TmdbEpisodeEnrichment(
|
||||||
|
val title: String?,
|
||||||
|
val overview: String?,
|
||||||
|
val thumbnail: String?,
|
||||||
|
val airDate: String?,
|
||||||
|
val runtimeMinutes: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun normalizeMetaType(type: String): String =
|
||||||
|
when (type.trim().lowercase()) {
|
||||||
|
"series", "tv", "show", "tvshow" -> "tv"
|
||||||
|
else -> "movie"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun normalizeTmdbLanguage(language: String?): String {
|
||||||
|
val raw = language
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.replace('_', '-')
|
||||||
|
?: return "en"
|
||||||
|
val parts = raw.split("-")
|
||||||
|
val normalized = if (parts.size == 2) {
|
||||||
|
"${parts[0].lowercase()}-${parts[1].uppercase()}"
|
||||||
|
} else {
|
||||||
|
raw.lowercase()
|
||||||
|
}
|
||||||
|
return when (normalized) {
|
||||||
|
"es-419" -> "es-MX"
|
||||||
|
else -> normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPeople(
|
||||||
|
details: TmdbDetailsResponse,
|
||||||
|
credits: TmdbCreditsResponse?,
|
||||||
|
mediaType: String,
|
||||||
|
): List<MetaPerson> {
|
||||||
|
val creators = if (mediaType == "tv") {
|
||||||
|
details.createdBy.mapNotNull { creator ->
|
||||||
|
val name = creator.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null
|
||||||
|
MetaPerson(
|
||||||
|
name = name,
|
||||||
|
role = "Creator",
|
||||||
|
photo = buildImageUrl(creator.profilePath, "w500"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val directors = credits?.crew.orEmpty()
|
||||||
|
.filter { it.job.equals("Director", ignoreCase = true) }
|
||||||
|
.mapNotNull { crew ->
|
||||||
|
val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null
|
||||||
|
MetaPerson(
|
||||||
|
name = name,
|
||||||
|
role = "Director",
|
||||||
|
photo = buildImageUrl(crew.profilePath, "w500"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val writers = credits?.crew.orEmpty()
|
||||||
|
.filter { crew ->
|
||||||
|
val job = crew.job?.lowercase().orEmpty()
|
||||||
|
job.contains("writer") || job.contains("screenplay")
|
||||||
|
}
|
||||||
|
.mapNotNull { crew ->
|
||||||
|
val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null
|
||||||
|
MetaPerson(
|
||||||
|
name = name,
|
||||||
|
role = "Writer",
|
||||||
|
photo = buildImageUrl(crew.profilePath, "w500"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val cast = credits?.cast.orEmpty()
|
||||||
|
.mapNotNull { castMember ->
|
||||||
|
val name = castMember.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null
|
||||||
|
MetaPerson(
|
||||||
|
name = name,
|
||||||
|
role = castMember.character?.trim()?.takeIf(String::isNotBlank),
|
||||||
|
photo = buildImageUrl(castMember.profilePath, "w500"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val primaryCrew = when {
|
||||||
|
mediaType == "tv" && creators.isNotEmpty() -> creators
|
||||||
|
mediaType != "tv" && directors.isNotEmpty() -> directors
|
||||||
|
else -> writers
|
||||||
|
}
|
||||||
|
|
||||||
|
return (primaryCrew + cast)
|
||||||
|
.dedupePeople()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDirectors(
|
||||||
|
details: TmdbDetailsResponse,
|
||||||
|
credits: TmdbCreditsResponse?,
|
||||||
|
mediaType: String,
|
||||||
|
): List<String> {
|
||||||
|
if (mediaType == "tv") {
|
||||||
|
return details.createdBy
|
||||||
|
.mapNotNull { it.name?.trim()?.takeIf(String::isNotBlank) }
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
return credits?.crew.orEmpty()
|
||||||
|
.filter { it.job.equals("Director", ignoreCase = true) }
|
||||||
|
.mapNotNull { it.name?.trim()?.takeIf(String::isNotBlank) }
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildWriters(
|
||||||
|
credits: TmdbCreditsResponse?,
|
||||||
|
mediaType: String,
|
||||||
|
hasDirectors: Boolean,
|
||||||
|
): List<String> {
|
||||||
|
if (hasDirectors) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return credits?.crew.orEmpty()
|
||||||
|
.filter { crew ->
|
||||||
|
val job = crew.job?.lowercase().orEmpty()
|
||||||
|
job.contains("writer") || job.contains("screenplay")
|
||||||
|
}
|
||||||
|
.mapNotNull { it.name?.trim()?.takeIf(String::isNotBlank) }
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<MetaPerson>.dedupePeople(): List<MetaPerson> {
|
||||||
|
val merged = linkedMapOf<String, MetaPerson>()
|
||||||
|
forEach { person ->
|
||||||
|
val key = person.name.lowercase() + "|" + person.role.orEmpty().lowercase()
|
||||||
|
val existing = merged[key]
|
||||||
|
merged[key] = if (existing == null) {
|
||||||
|
person
|
||||||
|
} else {
|
||||||
|
existing.copy(photo = existing.photo ?: person.photo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged.values.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildImageUrl(path: String?, size: String): String? {
|
||||||
|
val clean = path?.trim()?.takeIf(String::isNotBlank) ?: return null
|
||||||
|
return "https://image.tmdb.org/t/p/$size$clean"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<TmdbImage>.selectBestLocalizedImagePath(normalizedLanguage: String): String? {
|
||||||
|
if (isEmpty()) return null
|
||||||
|
val languageCode = normalizedLanguage.substringBefore("-")
|
||||||
|
val regionCode = normalizedLanguage.substringAfter("-", "").uppercase().takeIf { it.length == 2 }
|
||||||
|
?: defaultLanguageRegions[languageCode]
|
||||||
|
return sortedWith(
|
||||||
|
compareByDescending<TmdbImage> { it.iso6391 == languageCode && it.iso31661 == regionCode }
|
||||||
|
.thenByDescending { it.iso6391 == languageCode && it.iso31661 == null }
|
||||||
|
.thenByDescending { it.iso6391 == languageCode }
|
||||||
|
.thenByDescending { it.iso6391 == "en" }
|
||||||
|
.thenByDescending { it.iso6391 == null },
|
||||||
|
).firstOrNull()?.filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private val defaultLanguageRegions = mapOf(
|
||||||
|
"pt" to "PT",
|
||||||
|
"es" to "ES",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Double.formatRating(): String =
|
||||||
|
if (this == 0.0) {
|
||||||
|
"0.0"
|
||||||
|
} else {
|
||||||
|
(kotlin.math.round(this * 10.0) / 10.0).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.formatRuntime(): String = "${this}m"
|
||||||
|
|
||||||
|
private fun List<TmdbMovieReleaseDateCountry>.selectMovieAgeRating(normalizedLanguage: String): String? {
|
||||||
|
val preferredRegions = preferredRegions(normalizedLanguage)
|
||||||
|
val byRegion = associateBy { it.iso31661?.uppercase() }
|
||||||
|
preferredRegions.forEach { region ->
|
||||||
|
val rating = byRegion[region]
|
||||||
|
?.releaseDates
|
||||||
|
.orEmpty()
|
||||||
|
.mapNotNull { it.certification?.trim() }
|
||||||
|
.firstOrNull(String::isNotBlank)
|
||||||
|
if (!rating.isNullOrBlank()) return rating
|
||||||
|
}
|
||||||
|
return asSequence()
|
||||||
|
.flatMap { it.releaseDates.asSequence() }
|
||||||
|
.mapNotNull { it.certification?.trim() }
|
||||||
|
.firstOrNull(String::isNotBlank)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<TmdbTvContentRating>.selectTvAgeRating(normalizedLanguage: String): String? {
|
||||||
|
val preferredRegions = preferredRegions(normalizedLanguage)
|
||||||
|
val byRegion = associateBy { it.iso31661?.uppercase() }
|
||||||
|
preferredRegions.forEach { region ->
|
||||||
|
val rating = byRegion[region]?.rating?.trim()
|
||||||
|
if (!rating.isNullOrBlank()) return rating
|
||||||
|
}
|
||||||
|
return mapNotNull { it.rating?.trim() }.firstOrNull(String::isNotBlank)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preferredRegions(normalizedLanguage: String): List<String> {
|
||||||
|
val directRegion = normalizedLanguage.substringAfter("-", "").uppercase().takeIf { it.length == 2 }
|
||||||
|
return buildList {
|
||||||
|
if (!directRegion.isNullOrBlank()) add(directRegion)
|
||||||
|
add("US")
|
||||||
|
add("GB")
|
||||||
|
}.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TmdbCompany.toMetaCompany(): MetaCompany? {
|
||||||
|
val name = name?.trim()?.takeIf(String::isNotBlank) ?: return null
|
||||||
|
return MetaCompany(
|
||||||
|
name = name,
|
||||||
|
logo = buildImageUrl(logoPath, "w300"),
|
||||||
|
tmdbId = id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Quadruple<A, B, C, D>(
|
||||||
|
val first: A,
|
||||||
|
val second: B,
|
||||||
|
val third: C,
|
||||||
|
val fourth: D,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbDetailsResponse(
|
||||||
|
val title: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val overview: String? = null,
|
||||||
|
@SerialName("release_date") val releaseDate: String? = null,
|
||||||
|
@SerialName("first_air_date") val firstAirDate: String? = null,
|
||||||
|
val status: String? = null,
|
||||||
|
@SerialName("vote_average") val voteAverage: Double? = null,
|
||||||
|
val runtime: Int? = null,
|
||||||
|
@SerialName("episode_run_time") val episodeRunTime: List<Int> = emptyList(),
|
||||||
|
@SerialName("production_countries") val productionCountries: List<TmdbProductionCountry> = emptyList(),
|
||||||
|
@SerialName("origin_country") val originCountry: List<String> = emptyList(),
|
||||||
|
@SerialName("original_language") val originalLanguage: String? = null,
|
||||||
|
@SerialName("poster_path") val posterPath: String? = null,
|
||||||
|
@SerialName("backdrop_path") val backdropPath: String? = null,
|
||||||
|
@SerialName("created_by") val createdBy: List<TmdbCreator> = emptyList(),
|
||||||
|
val genres: List<TmdbNamedItem> = emptyList(),
|
||||||
|
@SerialName("production_companies") val productionCompanies: List<TmdbCompany> = emptyList(),
|
||||||
|
val networks: List<TmdbCompany> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbNamedItem(
|
||||||
|
val name: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbProductionCountry(
|
||||||
|
@SerialName("iso_3166_1") val iso31661: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbCreator(
|
||||||
|
val name: String? = null,
|
||||||
|
val id: Int? = null,
|
||||||
|
@SerialName("profile_path") val profilePath: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbCreditsResponse(
|
||||||
|
val cast: List<TmdbCastMember> = emptyList(),
|
||||||
|
val crew: List<TmdbCrewMember> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbCastMember(
|
||||||
|
val id: Int? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val character: String? = null,
|
||||||
|
@SerialName("profile_path") val profilePath: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbCrewMember(
|
||||||
|
val id: Int? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val job: String? = null,
|
||||||
|
@SerialName("profile_path") val profilePath: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbImagesResponse(
|
||||||
|
val logos: List<TmdbImage> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbImage(
|
||||||
|
@SerialName("file_path") val filePath: String? = null,
|
||||||
|
@SerialName("iso_639_1") val iso6391: String? = null,
|
||||||
|
@SerialName("iso_3166_1") val iso31661: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbMovieReleaseDatesResponse(
|
||||||
|
val results: List<TmdbMovieReleaseDateCountry> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbMovieReleaseDateCountry(
|
||||||
|
@SerialName("iso_3166_1") val iso31661: String? = null,
|
||||||
|
@SerialName("release_dates") val releaseDates: List<TmdbReleaseDate> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbReleaseDate(
|
||||||
|
val certification: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbTvContentRatingsResponse(
|
||||||
|
val results: List<TmdbTvContentRating> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbTvContentRating(
|
||||||
|
@SerialName("iso_3166_1") val iso31661: String? = null,
|
||||||
|
val rating: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbCompany(
|
||||||
|
val id: Int? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
@SerialName("logo_path") val logoPath: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbSeasonDetailsResponse(
|
||||||
|
val episodes: List<TmdbEpisodeResponse> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbEpisodeResponse(
|
||||||
|
val name: String? = null,
|
||||||
|
val overview: String? = null,
|
||||||
|
@SerialName("still_path") val stillPath: String? = null,
|
||||||
|
@SerialName("air_date") val airDate: String? = null,
|
||||||
|
val runtime: Int? = null,
|
||||||
|
@SerialName("episode_number") val episodeNumber: Int? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
package com.nuvio.app.features.tmdb
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.nuvio.app.features.addons.httpGetText
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
object TmdbService {
|
||||||
|
private val log = Logger.withTag("TmdbService")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val imdbToTmdbCache = linkedMapOf<String, String>()
|
||||||
|
private val tmdbToImdbCache = linkedMapOf<String, String>()
|
||||||
|
private val cacheMutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun ensureTmdbId(videoId: String, mediaType: String): String? {
|
||||||
|
if (TmdbConfig.API_KEY.isBlank()) return null
|
||||||
|
|
||||||
|
val normalized = videoId
|
||||||
|
.removePrefix("tmdb:")
|
||||||
|
.removePrefix("movie:")
|
||||||
|
.removePrefix("series:")
|
||||||
|
.substringBefore(':')
|
||||||
|
.substringBefore('/')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
if (normalized.isBlank()) return null
|
||||||
|
if (normalized.all(Char::isDigit)) return normalized
|
||||||
|
if (!normalized.startsWith("tt", ignoreCase = true)) return null
|
||||||
|
|
||||||
|
return imdbToTmdb(imdbId = normalized, mediaType = mediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun tmdbToImdb(tmdbId: Int, mediaType: String): String? {
|
||||||
|
if (TmdbConfig.API_KEY.isBlank()) return null
|
||||||
|
|
||||||
|
val cacheKey = "$tmdbId:${normalizeMediaType(mediaType)}"
|
||||||
|
cacheMutex.withLock {
|
||||||
|
tmdbToImdbCache[cacheKey]?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
val endpoint = when (normalizeMediaType(mediaType)) {
|
||||||
|
"tv" -> "tv/$tmdbId/external_ids"
|
||||||
|
else -> "movie/$tmdbId/external_ids"
|
||||||
|
}
|
||||||
|
val body = fetch<TmdbExternalIdsResponse>(endpoint) ?: return null
|
||||||
|
val imdbId = body.imdbId?.trim()?.takeIf(String::isNotBlank) ?: return null
|
||||||
|
|
||||||
|
cacheMutex.withLock {
|
||||||
|
tmdbToImdbCache[cacheKey] = imdbId
|
||||||
|
imdbToTmdbCache["$imdbId:${normalizeMediaType(mediaType)}"] = tmdbId.toString()
|
||||||
|
}
|
||||||
|
return imdbId
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun imdbToTmdb(imdbId: String, mediaType: String): String? {
|
||||||
|
val normalizedType = normalizeMediaType(mediaType)
|
||||||
|
val cacheKey = "$imdbId:$normalizedType"
|
||||||
|
cacheMutex.withLock {
|
||||||
|
imdbToTmdbCache[cacheKey]?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = fetch<TmdbFindResponse>(
|
||||||
|
endpoint = "find/$imdbId",
|
||||||
|
query = mapOf("external_source" to "imdb_id"),
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
val resultId = when (normalizedType) {
|
||||||
|
"movie" -> body.movieResults.firstOrNull()?.id
|
||||||
|
"tv" -> body.tvResults.firstOrNull()?.id
|
||||||
|
else -> body.movieResults.firstOrNull()?.id ?: body.tvResults.firstOrNull()?.id
|
||||||
|
}?.takeIf { it > 0 }?.toString()
|
||||||
|
|
||||||
|
if (resultId != null) {
|
||||||
|
cacheMutex.withLock {
|
||||||
|
imdbToTmdbCache[cacheKey] = resultId
|
||||||
|
tmdbToImdbCache["$resultId:$normalizedType"] = imdbId
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.d { "No TMDB ID found for $imdbId ($normalizedType)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultId
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> fetch(
|
||||||
|
endpoint: String,
|
||||||
|
query: Map<String, String> = emptyMap(),
|
||||||
|
): T? {
|
||||||
|
val url = buildTmdbUrl(endpoint = endpoint, query = query)
|
||||||
|
return runCatching {
|
||||||
|
json.decodeFromString<T>(httpGetText(url))
|
||||||
|
}.onFailure { error ->
|
||||||
|
log.w { "TMDB request failed for $endpoint: ${error.message}" }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun normalizeMediaType(mediaType: String): String =
|
||||||
|
when (mediaType.trim().lowercase()) {
|
||||||
|
"movie", "film" -> "movie"
|
||||||
|
"tv", "series", "show", "tvshow" -> "tv"
|
||||||
|
else -> mediaType.trim().lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildTmdbUrl(
|
||||||
|
endpoint: String,
|
||||||
|
query: Map<String, String> = emptyMap(),
|
||||||
|
): String {
|
||||||
|
val params = linkedMapOf("api_key" to TmdbConfig.API_KEY)
|
||||||
|
query.forEach { (key, value) ->
|
||||||
|
if (value.isNotBlank()) {
|
||||||
|
params[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildString {
|
||||||
|
append("https://api.themoviedb.org/3/")
|
||||||
|
append(endpoint.removePrefix("/"))
|
||||||
|
if (params.isNotEmpty()) {
|
||||||
|
append("?")
|
||||||
|
append(params.entries.joinToString("&") { (key, value) -> "$key=$value" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbFindResponse(
|
||||||
|
@SerialName("movie_results") val movieResults: List<TmdbExternalResult> = emptyList(),
|
||||||
|
@SerialName("tv_results") val tvResults: List<TmdbExternalResult> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbExternalResult(
|
||||||
|
val id: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TmdbExternalIdsResponse(
|
||||||
|
@SerialName("imdb_id") val imdbId: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.nuvio.app.features.tmdb
|
||||||
|
|
||||||
|
data class TmdbSettings(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val language: String = "en",
|
||||||
|
val useArtwork: Boolean = true,
|
||||||
|
val useBasicInfo: Boolean = true,
|
||||||
|
val useDetails: Boolean = true,
|
||||||
|
val useCredits: Boolean = true,
|
||||||
|
val useProductions: Boolean = true,
|
||||||
|
val useNetworks: Boolean = true,
|
||||||
|
val useEpisodes: Boolean = true,
|
||||||
|
val useMoreLikeThis: Boolean = true,
|
||||||
|
val useCollections: Boolean = true,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
package com.nuvio.app.features.tmdb
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
object TmdbSettingsRepository {
|
||||||
|
private val _uiState = MutableStateFlow(TmdbSettings())
|
||||||
|
val uiState: StateFlow<TmdbSettings> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var hasLoaded = false
|
||||||
|
|
||||||
|
private var enabled = false
|
||||||
|
private var language = "en"
|
||||||
|
private var useArtwork = true
|
||||||
|
private var useBasicInfo = true
|
||||||
|
private var useDetails = true
|
||||||
|
private var useCredits = true
|
||||||
|
private var useProductions = true
|
||||||
|
private var useNetworks = true
|
||||||
|
private var useEpisodes = true
|
||||||
|
private var useMoreLikeThis = true
|
||||||
|
private var useCollections = true
|
||||||
|
|
||||||
|
fun ensureLoaded() {
|
||||||
|
if (hasLoaded) return
|
||||||
|
loadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onProfileChanged() {
|
||||||
|
loadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(): TmdbSettings {
|
||||||
|
ensureLoaded()
|
||||||
|
return _uiState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(value: Boolean) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (enabled == value) return
|
||||||
|
enabled = value
|
||||||
|
publish()
|
||||||
|
TmdbSettingsStorage.saveEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLanguage(value: String) {
|
||||||
|
ensureLoaded()
|
||||||
|
val normalized = normalizeLanguage(value)
|
||||||
|
if (language == normalized) return
|
||||||
|
language = normalized
|
||||||
|
publish()
|
||||||
|
TmdbSettingsStorage.saveLanguage(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUseArtwork(value: Boolean) = setBoolean(
|
||||||
|
current = useArtwork,
|
||||||
|
next = value,
|
||||||
|
update = { useArtwork = it },
|
||||||
|
persist = TmdbSettingsStorage::saveUseArtwork,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setUseBasicInfo(value: Boolean) = setBoolean(
|
||||||
|
current = useBasicInfo,
|
||||||
|
next = value,
|
||||||
|
update = { useBasicInfo = it },
|
||||||
|
persist = TmdbSettingsStorage::saveUseBasicInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setUseDetails(value: Boolean) = setBoolean(
|
||||||
|
current = useDetails,
|
||||||
|
next = value,
|
||||||
|
update = { useDetails = it },
|
||||||
|
persist = TmdbSettingsStorage::saveUseDetails,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setUseCredits(value: Boolean) = setBoolean(
|
||||||
|
current = useCredits,
|
||||||
|
next = value,
|
||||||
|
update = { useCredits = it },
|
||||||
|
persist = TmdbSettingsStorage::saveUseCredits,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setUseProductions(value: Boolean) = setBoolean(
|
||||||
|
current = useProductions,
|
||||||
|
next = value,
|
||||||
|
update = { useProductions = it },
|
||||||
|
persist = TmdbSettingsStorage::saveUseProductions,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setUseNetworks(value: Boolean) = setBoolean(
|
||||||
|
current = useNetworks,
|
||||||
|
next = value,
|
||||||
|
update = { useNetworks = it },
|
||||||
|
persist = TmdbSettingsStorage::saveUseNetworks,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setUseEpisodes(value: Boolean) = setBoolean(
|
||||||
|
current = useEpisodes,
|
||||||
|
next = value,
|
||||||
|
update = { useEpisodes = it },
|
||||||
|
persist = TmdbSettingsStorage::saveUseEpisodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setUseMoreLikeThis(value: Boolean) = setBoolean(
|
||||||
|
current = useMoreLikeThis,
|
||||||
|
next = value,
|
||||||
|
update = { useMoreLikeThis = it },
|
||||||
|
persist = TmdbSettingsStorage::saveUseMoreLikeThis,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setUseCollections(value: Boolean) = setBoolean(
|
||||||
|
current = useCollections,
|
||||||
|
next = value,
|
||||||
|
update = { useCollections = it },
|
||||||
|
persist = TmdbSettingsStorage::saveUseCollections,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun setBoolean(
|
||||||
|
current: Boolean,
|
||||||
|
next: Boolean,
|
||||||
|
update: (Boolean) -> Unit,
|
||||||
|
persist: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (current == next) return
|
||||||
|
update(next)
|
||||||
|
publish()
|
||||||
|
persist(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFromDisk() {
|
||||||
|
hasLoaded = true
|
||||||
|
enabled = TmdbSettingsStorage.loadEnabled() ?: false
|
||||||
|
language = normalizeLanguage(TmdbSettingsStorage.loadLanguage())
|
||||||
|
useArtwork = TmdbSettingsStorage.loadUseArtwork() ?: true
|
||||||
|
useBasicInfo = TmdbSettingsStorage.loadUseBasicInfo() ?: true
|
||||||
|
useDetails = TmdbSettingsStorage.loadUseDetails() ?: true
|
||||||
|
useCredits = TmdbSettingsStorage.loadUseCredits() ?: true
|
||||||
|
useProductions = TmdbSettingsStorage.loadUseProductions() ?: true
|
||||||
|
useNetworks = TmdbSettingsStorage.loadUseNetworks() ?: true
|
||||||
|
useEpisodes = TmdbSettingsStorage.loadUseEpisodes() ?: true
|
||||||
|
useMoreLikeThis = TmdbSettingsStorage.loadUseMoreLikeThis() ?: true
|
||||||
|
useCollections = TmdbSettingsStorage.loadUseCollections() ?: true
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
_uiState.value = TmdbSettings(
|
||||||
|
enabled = enabled,
|
||||||
|
language = language,
|
||||||
|
useArtwork = useArtwork,
|
||||||
|
useBasicInfo = useBasicInfo,
|
||||||
|
useDetails = useDetails,
|
||||||
|
useCredits = useCredits,
|
||||||
|
useProductions = useProductions,
|
||||||
|
useNetworks = useNetworks,
|
||||||
|
useEpisodes = useEpisodes,
|
||||||
|
useMoreLikeThis = useMoreLikeThis,
|
||||||
|
useCollections = useCollections,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun normalizeLanguage(value: String?): String =
|
||||||
|
value
|
||||||
|
?.trim()
|
||||||
|
?.replace('_', '-')
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: "en"
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.nuvio.app.features.tmdb
|
||||||
|
|
||||||
|
internal expect object TmdbSettingsStorage {
|
||||||
|
fun loadEnabled(): Boolean?
|
||||||
|
fun saveEnabled(enabled: Boolean)
|
||||||
|
fun loadLanguage(): String?
|
||||||
|
fun saveLanguage(language: String)
|
||||||
|
fun loadUseArtwork(): Boolean?
|
||||||
|
fun saveUseArtwork(enabled: Boolean)
|
||||||
|
fun loadUseBasicInfo(): Boolean?
|
||||||
|
fun saveUseBasicInfo(enabled: Boolean)
|
||||||
|
fun loadUseDetails(): Boolean?
|
||||||
|
fun saveUseDetails(enabled: Boolean)
|
||||||
|
fun loadUseCredits(): Boolean?
|
||||||
|
fun saveUseCredits(enabled: Boolean)
|
||||||
|
fun loadUseProductions(): Boolean?
|
||||||
|
fun saveUseProductions(enabled: Boolean)
|
||||||
|
fun loadUseNetworks(): Boolean?
|
||||||
|
fun saveUseNetworks(enabled: Boolean)
|
||||||
|
fun loadUseEpisodes(): Boolean?
|
||||||
|
fun saveUseEpisodes(enabled: Boolean)
|
||||||
|
fun loadUseMoreLikeThis(): Boolean?
|
||||||
|
fun saveUseMoreLikeThis(enabled: Boolean)
|
||||||
|
fun loadUseCollections(): Boolean?
|
||||||
|
fun saveUseCollections(enabled: Boolean)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
package com.nuvio.app.features.tmdb
|
||||||
|
|
||||||
|
import com.nuvio.app.features.details.MetaCompany
|
||||||
|
import com.nuvio.app.features.details.MetaDetails
|
||||||
|
import com.nuvio.app.features.details.MetaPerson
|
||||||
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class TmdbMetadataServiceTest {
|
||||||
|
@Test
|
||||||
|
fun `applyEnrichment replaces enabled metadata groups`() {
|
||||||
|
val base = MetaDetails(
|
||||||
|
id = "tt1234567",
|
||||||
|
type = "series",
|
||||||
|
name = "Original",
|
||||||
|
description = "Addon description",
|
||||||
|
videos = listOf(
|
||||||
|
MetaVideo(
|
||||||
|
id = "ep1",
|
||||||
|
title = "Episode 1",
|
||||||
|
season = 1,
|
||||||
|
episode = 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val enrichment = TmdbEnrichment(
|
||||||
|
localizedTitle = "Localized",
|
||||||
|
description = "TMDB description",
|
||||||
|
genres = listOf("Drama", "Mystery"),
|
||||||
|
backdrop = "https://example.com/backdrop.jpg",
|
||||||
|
logo = "https://example.com/logo.png",
|
||||||
|
poster = "https://example.com/poster.jpg",
|
||||||
|
people = listOf(MetaPerson(name = "Person", role = "Creator")),
|
||||||
|
director = listOf("Director Name"),
|
||||||
|
writer = emptyList(),
|
||||||
|
releaseInfo = "2024-01-01",
|
||||||
|
rating = 8.4,
|
||||||
|
runtimeMinutes = 52,
|
||||||
|
ageRating = "TV-MA",
|
||||||
|
status = "Returning Series",
|
||||||
|
countries = listOf("US"),
|
||||||
|
language = "en",
|
||||||
|
productionCompanies = listOf(MetaCompany(name = "A24")),
|
||||||
|
networks = listOf(MetaCompany(name = "HBO")),
|
||||||
|
)
|
||||||
|
val episodes = mapOf(
|
||||||
|
(1 to 1) to TmdbEpisodeEnrichment(
|
||||||
|
title = "Pilot",
|
||||||
|
overview = "Episode overview",
|
||||||
|
thumbnail = "https://example.com/thumb.jpg",
|
||||||
|
airDate = "2024-01-01",
|
||||||
|
runtimeMinutes = 58,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = TmdbMetadataService.applyEnrichment(
|
||||||
|
meta = base,
|
||||||
|
enrichment = enrichment,
|
||||||
|
episodeMap = episodes,
|
||||||
|
settings = TmdbSettings(enabled = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Localized", result.name)
|
||||||
|
assertEquals("TMDB description", result.description)
|
||||||
|
assertEquals(listOf("Drama", "Mystery"), result.genres)
|
||||||
|
assertEquals("8.4", result.imdbRating)
|
||||||
|
assertEquals("TV-MA", result.ageRating)
|
||||||
|
assertEquals("52m", result.runtime)
|
||||||
|
assertEquals(listOf("Director Name"), result.director)
|
||||||
|
assertEquals(listOf("A24"), result.productionCompanies.map { it.name })
|
||||||
|
assertEquals(listOf("HBO"), result.networks.map { it.name })
|
||||||
|
assertEquals("Pilot", result.videos.first().title)
|
||||||
|
assertEquals(58, result.videos.first().runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyEnrichment preserves disabled groups`() {
|
||||||
|
val base = MetaDetails(
|
||||||
|
id = "tt7654321",
|
||||||
|
type = "movie",
|
||||||
|
name = "Original",
|
||||||
|
description = "Original description",
|
||||||
|
videos = listOf(
|
||||||
|
MetaVideo(
|
||||||
|
id = "movie",
|
||||||
|
title = "Original title",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val enrichment = TmdbEnrichment(
|
||||||
|
localizedTitle = "Localized",
|
||||||
|
description = "TMDB description",
|
||||||
|
genres = listOf("Sci-Fi"),
|
||||||
|
backdrop = "backdrop",
|
||||||
|
logo = "logo",
|
||||||
|
poster = "poster",
|
||||||
|
people = listOf(MetaPerson(name = "Cast Member")),
|
||||||
|
director = listOf("Director"),
|
||||||
|
writer = listOf("Writer"),
|
||||||
|
releaseInfo = "2025-05-05",
|
||||||
|
rating = 7.2,
|
||||||
|
runtimeMinutes = 124,
|
||||||
|
ageRating = "PG-13",
|
||||||
|
status = "Released",
|
||||||
|
countries = listOf("US"),
|
||||||
|
language = "en",
|
||||||
|
productionCompanies = listOf(MetaCompany(name = "Studio")),
|
||||||
|
networks = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = TmdbMetadataService.applyEnrichment(
|
||||||
|
meta = base,
|
||||||
|
enrichment = enrichment,
|
||||||
|
episodeMap = emptyMap(),
|
||||||
|
settings = TmdbSettings(
|
||||||
|
enabled = true,
|
||||||
|
useArtwork = false,
|
||||||
|
useBasicInfo = false,
|
||||||
|
useDetails = false,
|
||||||
|
useCredits = false,
|
||||||
|
useProductions = false,
|
||||||
|
useNetworks = false,
|
||||||
|
useEpisodes = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(base.name, result.name)
|
||||||
|
assertEquals(base.description, result.description)
|
||||||
|
assertEquals(base.genres, result.genres)
|
||||||
|
assertEquals(base.director, result.director)
|
||||||
|
assertEquals(base.cast, result.cast)
|
||||||
|
assertEquals(base.productionCompanies, result.productionCompanies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package com.nuvio.app.features.tmdb
|
||||||
|
|
||||||
|
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||||
|
import platform.Foundation.NSUserDefaults
|
||||||
|
|
||||||
|
actual object TmdbSettingsStorage {
|
||||||
|
private const val enabledKey = "tmdb_enabled"
|
||||||
|
private const val languageKey = "tmdb_language"
|
||||||
|
private const val useArtworkKey = "tmdb_use_artwork"
|
||||||
|
private const val useBasicInfoKey = "tmdb_use_basic_info"
|
||||||
|
private const val useDetailsKey = "tmdb_use_details"
|
||||||
|
private const val useCreditsKey = "tmdb_use_credits"
|
||||||
|
private const val useProductionsKey = "tmdb_use_productions"
|
||||||
|
private const val useNetworksKey = "tmdb_use_networks"
|
||||||
|
private const val useEpisodesKey = "tmdb_use_episodes"
|
||||||
|
private const val useMoreLikeThisKey = "tmdb_use_more_like_this"
|
||||||
|
private const val useCollectionsKey = "tmdb_use_collections"
|
||||||
|
|
||||||
|
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
|
||||||
|
|
||||||
|
actual fun saveEnabled(enabled: Boolean) {
|
||||||
|
saveBoolean(enabledKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadLanguage(): String? =
|
||||||
|
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(languageKey))
|
||||||
|
|
||||||
|
actual fun saveLanguage(language: String) {
|
||||||
|
NSUserDefaults.standardUserDefaults.setObject(language, forKey = ProfileScopedKey.of(languageKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseArtwork(): Boolean? = loadBoolean(useArtworkKey)
|
||||||
|
|
||||||
|
actual fun saveUseArtwork(enabled: Boolean) {
|
||||||
|
saveBoolean(useArtworkKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseBasicInfo(): Boolean? = loadBoolean(useBasicInfoKey)
|
||||||
|
|
||||||
|
actual fun saveUseBasicInfo(enabled: Boolean) {
|
||||||
|
saveBoolean(useBasicInfoKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseDetails(): Boolean? = loadBoolean(useDetailsKey)
|
||||||
|
|
||||||
|
actual fun saveUseDetails(enabled: Boolean) {
|
||||||
|
saveBoolean(useDetailsKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey)
|
||||||
|
|
||||||
|
actual fun saveUseCredits(enabled: Boolean) {
|
||||||
|
saveBoolean(useCreditsKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseProductions(): Boolean? = loadBoolean(useProductionsKey)
|
||||||
|
|
||||||
|
actual fun saveUseProductions(enabled: Boolean) {
|
||||||
|
saveBoolean(useProductionsKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseNetworks(): Boolean? = loadBoolean(useNetworksKey)
|
||||||
|
|
||||||
|
actual fun saveUseNetworks(enabled: Boolean) {
|
||||||
|
saveBoolean(useNetworksKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseEpisodes(): Boolean? = loadBoolean(useEpisodesKey)
|
||||||
|
|
||||||
|
actual fun saveUseEpisodes(enabled: Boolean) {
|
||||||
|
saveBoolean(useEpisodesKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseMoreLikeThis(): Boolean? = loadBoolean(useMoreLikeThisKey)
|
||||||
|
|
||||||
|
actual fun saveUseMoreLikeThis(enabled: Boolean) {
|
||||||
|
saveBoolean(useMoreLikeThisKey, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun loadUseCollections(): Boolean? = loadBoolean(useCollectionsKey)
|
||||||
|
|
||||||
|
actual fun saveUseCollections(enabled: Boolean) {
|
||||||
|
saveBoolean(useCollectionsKey, 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue