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()
|
||||
)
|
||||
}
|
||||
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 {
|
||||
androidTarget {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsStorage
|
|||
import com.nuvio.app.features.player.PlayerSettingsStorage
|
||||
import com.nuvio.app.features.profiles.ProfileStorage
|
||||
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.streams.StreamLinkCacheStorage
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage
|
||||
|
|
@ -37,6 +38,7 @@ class MainActivity : ComponentActivity() {
|
|||
PlayerSettingsStorage.initialize(applicationContext)
|
||||
ProfileStorage.initialize(applicationContext)
|
||||
ThemeSettingsStorage.initialize(applicationContext)
|
||||
TmdbSettingsStorage.initialize(applicationContext)
|
||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||
WatchProgressStorage.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 status: String? = null,
|
||||
val imdbRating: String? = null,
|
||||
val ageRating: String? = null,
|
||||
val runtime: String? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val director: List<String> = emptyList(),
|
||||
val writer: List<String> = emptyList(),
|
||||
val cast: List<MetaPerson> = emptyList(),
|
||||
val productionCompanies: List<MetaCompany> = emptyList(),
|
||||
val networks: List<MetaCompany> = emptyList(),
|
||||
val country: String? = null,
|
||||
val awards: String? = null,
|
||||
val language: String? = null,
|
||||
|
|
@ -32,6 +36,12 @@ data class MetaPerson(
|
|||
val photo: String? = null,
|
||||
)
|
||||
|
||||
data class MetaCompany(
|
||||
val name: String,
|
||||
val logo: String? = null,
|
||||
val tmdbId: Int? = null,
|
||||
)
|
||||
|
||||
data class MetaLink(
|
||||
val name: String,
|
||||
val category: String,
|
||||
|
|
@ -46,6 +56,7 @@ data class MetaVideo(
|
|||
val season: Int? = null,
|
||||
val episode: Int? = null,
|
||||
val overview: String? = null,
|
||||
val runtime: Int? = null,
|
||||
val streams: List<StreamItem> = emptyList(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,9 +34,11 @@ internal object MetaDetailsParser {
|
|||
releaseInfo = meta.string("releaseInfo"),
|
||||
status = meta.string("status"),
|
||||
imdbRating = meta.string("imdbRating"),
|
||||
ageRating = meta.string("ageRating"),
|
||||
runtime = meta.string("runtime"),
|
||||
genres = meta.stringList("genres"),
|
||||
director = meta.directors(links),
|
||||
writer = meta.writers(links),
|
||||
cast = meta.cast(links),
|
||||
country = meta.string("country"),
|
||||
awards = meta.string("awards"),
|
||||
|
|
@ -137,6 +139,22 @@ internal object MetaDetailsParser {
|
|||
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> =
|
||||
people(name).map(MetaPerson::name)
|
||||
|
||||
|
|
@ -206,6 +224,7 @@ internal object MetaDetailsParser {
|
|||
season = video.int("season"),
|
||||
episode = video.int("episode"),
|
||||
overview = video.string("overview") ?: video.string("description"),
|
||||
runtime = video.int("runtime"),
|
||||
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.AddonRepository
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
|
@ -117,6 +119,7 @@ object MetaDetailsRepository {
|
|||
}
|
||||
|
||||
private const val FETCH_TIMEOUT_MS = 5_000L
|
||||
private const val TMDB_ENRICH_TIMEOUT_MS = 5_000L
|
||||
|
||||
private suspend fun tryFetchMeta(
|
||||
manifest: AddonManifest,
|
||||
|
|
@ -124,6 +127,7 @@ object MetaDetailsRepository {
|
|||
id: String,
|
||||
): MetaDetails? {
|
||||
return try {
|
||||
TmdbSettingsRepository.ensureLoaded()
|
||||
val baseUrl = manifest.transportUrl
|
||||
.substringBefore("?")
|
||||
.removeSuffix("/manifest.json")
|
||||
|
|
@ -132,12 +136,19 @@ object MetaDetailsRepository {
|
|||
val payload = httpGetText(url)
|
||||
log.d { "Raw payload length=${payload.length}, first 500 chars: ${payload.take(500)}" }
|
||||
val result = MetaDetailsParser.parse(payload)
|
||||
log.d { "Parsed meta: type=${result.type}, name=${result.name}, videos=${result.videos.size}" }
|
||||
if (result.videos.isNotEmpty()) {
|
||||
val first = result.videos.first()
|
||||
val enriched = withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) {
|
||||
TmdbMetadataService.enrichMeta(
|
||||
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}" }
|
||||
}
|
||||
result
|
||||
enriched
|
||||
} catch (e: Throwable) {
|
||||
log.e(e) { "Failed to fetch/parse meta from ${manifest.transportUrl}" }
|
||||
null
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ fun DetailMetaInfo(
|
|||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Year, Runtime, IMDb rating row
|
||||
val infoParts = buildList {
|
||||
meta.releaseInfo?.let { add(it) }
|
||||
meta.ageRating?.let { add(it) }
|
||||
meta.runtime?.let { add(it.uppercase()) }
|
||||
}
|
||||
if (infoParts.isNotEmpty() || meta.imdbRating != null) {
|
||||
|
|
@ -88,24 +88,50 @@ fun DetailMetaInfo(
|
|||
}
|
||||
}
|
||||
|
||||
// Director
|
||||
if (meta.director.isNotEmpty()) {
|
||||
Row {
|
||||
Text(
|
||||
text = "Director: ",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = meta.director.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val detailChips = buildList {
|
||||
meta.status?.let { add(it) }
|
||||
meta.country?.let { add(it) }
|
||||
meta.language?.let { add(it.uppercase()) }
|
||||
}
|
||||
if (detailChips.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
detailChips.forEach { chip ->
|
||||
DetailChip(label = chip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
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 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.player.PlayerSettingsRepository
|
||||
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.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
|
|
@ -122,6 +123,7 @@ object ProfileRepository {
|
|||
PlayerSettingsRepository.onProfileChanged()
|
||||
HomeCatalogSettingsRepository.onProfileChanged()
|
||||
ContinueWatchingPreferencesRepository.onProfileChanged()
|
||||
TmdbSettingsRepository.onProfileChanged()
|
||||
}
|
||||
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Extension
|
||||
import androidx.compose.material.icons.rounded.MovieFilter
|
||||
import androidx.compose.material.icons.rounded.Tune
|
||||
|
||||
internal fun LazyListScope.contentDiscoveryContent(
|
||||
isTablet: Boolean,
|
||||
onAddonsClick: () -> Unit,
|
||||
onHomescreenClick: () -> Unit,
|
||||
onTmdbClick: () -> Unit,
|
||||
) {
|
||||
item {
|
||||
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 {
|
||||
SettingsSection(
|
||||
title = "HOME",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
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.ui.graphics.vector.ImageVector
|
||||
|
||||
|
|
@ -20,6 +18,7 @@ internal enum class SettingsPage(
|
|||
Playback("Playback"),
|
||||
Appearance("Appearance"),
|
||||
ContentDiscovery("Content & Discovery"),
|
||||
TmdbEnrichment("TMDB Enrichment"),
|
||||
}
|
||||
|
||||
internal fun SettingsPage.previousPage(): SettingsPage? =
|
||||
|
|
@ -28,4 +27,5 @@ internal fun SettingsPage.previousPage(): SettingsPage? =
|
|||
SettingsPage.Playback -> SettingsPage.Root
|
||||
SettingsPage.Appearance -> 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.NuvioScreenHeader
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.tmdb.TmdbSettings
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
|
|
@ -62,6 +64,10 @@ fun SettingsScreen(
|
|||
ThemeSettingsRepository.selectedTheme
|
||||
}.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) }
|
||||
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
|
||||
|
|
@ -90,6 +96,7 @@ fun SettingsScreen(
|
|||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||
amoledEnabled = amoledEnabled,
|
||||
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
||||
tmdbSettings = tmdbSettings,
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onHomescreenClick = onHomescreenClick,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
|
|
@ -114,6 +121,7 @@ fun SettingsScreen(
|
|||
onThemeSelected = ThemeSettingsRepository::setTheme,
|
||||
amoledEnabled = amoledEnabled,
|
||||
onAmoledToggle = ThemeSettingsRepository::setAmoled,
|
||||
tmdbSettings = tmdbSettings,
|
||||
onSwitchProfile = onSwitchProfile,
|
||||
onHomescreenClick = onHomescreenClick,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
|
|
@ -142,6 +150,7 @@ private fun MobileSettingsScreen(
|
|||
onThemeSelected: (AppTheme) -> Unit,
|
||||
amoledEnabled: Boolean,
|
||||
onAmoledToggle: (Boolean) -> Unit,
|
||||
tmdbSettings: TmdbSettings,
|
||||
onSwitchProfile: (() -> Unit)? = null,
|
||||
onHomescreenClick: () -> Unit = {},
|
||||
onContinueWatchingClick: () -> Unit = {},
|
||||
|
|
@ -191,6 +200,11 @@ private fun MobileSettingsScreen(
|
|||
isTablet = false,
|
||||
onAddonsClick = onAddonsClick,
|
||||
onHomescreenClick = onHomescreenClick,
|
||||
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
|
||||
)
|
||||
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
|
||||
isTablet = false,
|
||||
settings = tmdbSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -214,6 +228,7 @@ private fun TabletSettingsScreen(
|
|||
onThemeSelected: (AppTheme) -> Unit,
|
||||
amoledEnabled: Boolean,
|
||||
onAmoledToggle: (Boolean) -> Unit,
|
||||
tmdbSettings: TmdbSettings,
|
||||
onSwitchProfile: (() -> Unit)? = null,
|
||||
onHomescreenClick: () -> Unit = {},
|
||||
onContinueWatchingClick: () -> Unit = {},
|
||||
|
|
@ -312,6 +327,11 @@ private fun TabletSettingsScreen(
|
|||
isTablet = true,
|
||||
onAddonsClick = onAddonsClick,
|
||||
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