From ca2be5fdb2305fffd9b3fc5c2a8d7e69aaa5a1e2 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:20:36 +0530 Subject: [PATCH] tmdb init --- composeApp/build.gradle.kts | 12 + .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../tmdb/TmdbSettingsStorage.android.kt | 113 +++ .../app/features/details/MetaDetailsModels.kt | 11 + .../app/features/details/MetaDetailsParser.kt | 19 + .../features/details/MetaDetailsRepository.kt | 19 +- .../details/components/DetailMetaInfo.kt | 94 ++- .../features/profiles/ProfileRepository.kt | 2 + .../settings/ContentDiscoverySettingsPage.kt | 18 + .../app/features/settings/SettingsModels.kt | 4 +- .../app/features/settings/SettingsScreen.kt | 20 + .../app/features/settings/TmdbSettingsPage.kt | 223 ++++++ .../app/features/tmdb/TmdbMetadataService.kt | 688 ++++++++++++++++++ .../nuvio/app/features/tmdb/TmdbService.kt | 142 ++++ .../nuvio/app/features/tmdb/TmdbSettings.kt | 15 + .../features/tmdb/TmdbSettingsRepository.kt | 170 +++++ .../app/features/tmdb/TmdbSettingsStorage.kt | 26 + .../features/tmdb/TmdbMetadataServiceTest.kt | 135 ++++ .../features/tmdb/TmdbSettingsStorage.ios.kt | 99 +++ 19 files changed, 1790 insertions(+), 22 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbService.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9b83e148..f91b38e8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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 { diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 961e41ba..e2fa4424 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt new file mode 100644 index 00000000..b7455071 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt @@ -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() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index 3ff4f415..3a92f977 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -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 = emptyList(), val director: List = emptyList(), + val writer: List = emptyList(), val cast: List = emptyList(), + val productionCompanies: List = emptyList(), + val networks: List = 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 = emptyList(), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index a05e4b9e..5be07635 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -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): List { + 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 = 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(), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 20802242..1dc279fc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt index e0735e6b..5ad3bfcc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 45e323c5..d1b7a8ba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt index 5f58804f..71006fa8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt @@ -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", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index 5b52cced..c3d2be1c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -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 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 9e47197b..0d188474 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt new file mode 100644 index 00000000..c8bd061a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt @@ -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, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt new file mode 100644 index 00000000..c7cc0816 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -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() + private val episodeCache = mutableMapOf, 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, 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( + endpoint = "$mediaType/$numericId", + query = mapOf("language" to normalizedLanguage), + ) + } + val credits = async { + fetch( + endpoint = "$mediaType/$numericId/credits", + query = mapOf("language" to normalizedLanguage), + ) + } + val images = async { + fetch( + endpoint = "$mediaType/$numericId/images", + query = mapOf("include_image_language" to includeImageLanguage), + ) + } + val ageRating = async { + when (mediaType) { + "tv" -> fetch( + endpoint = "tv/$numericId/content_ratings", + )?.results.orEmpty().selectTvAgeRating(normalizedLanguage) + else -> fetch( + 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, + language: String, + ): Map, 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( + 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, TmdbEpisodeEnrichment>()) { acc, value -> acc + value } + if (merged.isNotEmpty()) { + episodeCache[cacheKey] = merged + } + merged + } + + private suspend inline fun fetch( + endpoint: String, + query: Map = emptyMap(), + ): T? { + val url = buildTmdbUrl(endpoint = endpoint, query = query) + return runCatching { + json.decodeFromString(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, + val backdrop: String?, + val logo: String?, + val poster: String?, + val people: List, + val director: List, + val writer: List, + val releaseInfo: String?, + val rating: Double?, + val runtimeMinutes: Int?, + val ageRating: String?, + val status: String?, + val countries: List, + val language: String?, + val productionCompanies: List, + val networks: List, +) { + 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 { + 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 { + 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 { + 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.dedupePeople(): List { + val merged = linkedMapOf() + 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.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 { 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.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.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 { + 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( + 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 = emptyList(), + @SerialName("production_countries") val productionCountries: List = emptyList(), + @SerialName("origin_country") val originCountry: List = 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 = emptyList(), + val genres: List = emptyList(), + @SerialName("production_companies") val productionCompanies: List = emptyList(), + val networks: List = 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 = emptyList(), + val crew: List = 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 = 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 = emptyList(), +) + +@Serializable +private data class TmdbMovieReleaseDateCountry( + @SerialName("iso_3166_1") val iso31661: String? = null, + @SerialName("release_dates") val releaseDates: List = emptyList(), +) + +@Serializable +private data class TmdbReleaseDate( + val certification: String? = null, +) + +@Serializable +private data class TmdbTvContentRatingsResponse( + val results: List = 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 = 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, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbService.kt new file mode 100644 index 00000000..7aeae755 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbService.kt @@ -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() + private val tmdbToImdbCache = linkedMapOf() + 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(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( + 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 fetch( + endpoint: String, + query: Map = emptyMap(), + ): T? { + val url = buildTmdbUrl(endpoint = endpoint, query = query) + return runCatching { + json.decodeFromString(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 = 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 = emptyList(), + @SerialName("tv_results") val tvResults: List = emptyList(), +) + +@Serializable +private data class TmdbExternalResult( + val id: Int, +) + +@Serializable +private data class TmdbExternalIdsResponse( + @SerialName("imdb_id") val imdbId: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt new file mode 100644 index 00000000..fd37d481 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt @@ -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, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt new file mode 100644 index 00000000..20098dd9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt @@ -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 = _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" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt new file mode 100644 index 00000000..a061f96f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt @@ -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) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt new file mode 100644 index 00000000..d4145c30 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt @@ -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) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt new file mode 100644 index 00000000..f05f18c3 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt @@ -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)) + } +}