tmdb init

This commit is contained in:
tapframe 2026-03-30 20:20:36 +05:30
parent d00b4ae2e1
commit ca2be5fdb2
19 changed files with 1790 additions and 22 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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(),
)

View file

@ -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(),
)
}

View file

@ -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

View file

@ -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)

View file

@ -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>) {

View file

@ -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",

View file

@ -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
}

View file

@ -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,
)
}
}

View file

@ -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,
)
}

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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"

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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))
}
}