From 9b3298fe2cfa846e38eca76aeaf86ccc32c717e7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:56:23 +0530 Subject: [PATCH] feat: trakt as a source for morelikethis --- .../composeResources/values/strings.xml | 8 + .../app/features/details/MetaDetailsModels.kt | 6 + .../features/details/MetaDetailsRepository.kt | 115 +++++- .../app/features/details/MetaDetailsScreen.kt | 32 ++ .../components/DetailPosterRailSection.kt | 58 +++- .../app/features/settings/SettingsSearch.kt | 1 + .../features/settings/TraktSettingsPage.kt | 88 +++++ .../app/features/tmdb/TmdbMetadataService.kt | 7 +- .../features/trakt/TraktRelatedRepository.kt | 327 ++++++++++++++++++ .../features/trakt/TraktSettingsRepository.kt | 29 ++ .../trakt/TraktSettingsRepositoryTest.kt | 35 ++ 11 files changed, 683 insertions(+), 23 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index efbfeaf66..c8fe87a75 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1008,6 +1008,12 @@ Nuvio Sync Watch progress source set to Trakt Watch progress source set to Nuvio Sync + More Like This source + Choose where recommendations come from on detail pages + More Like This source + Select the source for recommendations shown on detail pages. + Trakt + TMDB Continue Watching Window Trakt history considered for continue watching Continue Watching Window @@ -1133,6 +1139,8 @@ Director Failed to load More Like This + Powered by TMDB + Powered by Trakt Seasons This addon returned videos for the series, but none included season or episode numbers. This addon did not provide episode metadata for this series. 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 3ac52ded4..de32bc32b 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 @@ -31,6 +31,7 @@ data class MetaDetails( val website: String? = null, val hasScheduledVideos: Boolean = false, val moreLikeThis: List = emptyList(), + val moreLikeThisSource: MoreLikeThisSource? = null, val collectionName: String? = null, val collectionItems: List = emptyList(), val trailers: List = emptyList(), @@ -38,6 +39,11 @@ data class MetaDetails( val videos: List = emptyList(), ) +enum class MoreLikeThisSource { + TMDB, + TRAKT, +} + data class MetaExternalRating( val source: String, val value: Double, 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 fcb432b94..b1654a892 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 @@ -13,6 +13,11 @@ import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbSettingsRepository +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.TraktRelatedRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.shouldUseTraktMoreLikeThis import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -58,7 +63,7 @@ object MetaDetailsRepository { } val cachedBaseMeta = cachedEntry.baseMeta - if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) { + if (!shouldEnrichForMetaScreen(cachedBaseMeta, id, mdbListSettings)) { _uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter()) activeRequestKey = requestKey return @@ -81,6 +86,7 @@ object MetaDetailsRepository { requestKey = requestKey, meta = cachedBaseMeta, fallbackItemId = id, + fallbackItemType = type, settings = mdbListSettings, settingsFingerprint = metaScreenSettingsFingerprint, ) @@ -116,6 +122,7 @@ object MetaDetailsRepository { requestKey = requestKey, meta = tmdbMeta, fallbackItemId = id, + fallbackItemType = type, mdbListSettings = mdbListSettings, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, ) @@ -139,6 +146,7 @@ object MetaDetailsRepository { requestKey = requestKey, meta = result, fallbackItemId = metaLookupId, + fallbackItemType = type, mdbListSettings = mdbListSettings, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, ) @@ -152,6 +160,7 @@ object MetaDetailsRepository { requestKey = requestKey, meta = tmdbMeta, fallbackItemId = id, + fallbackItemType = type, mdbListSettings = mdbListSettings, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, ) @@ -300,13 +309,14 @@ object MetaDetailsRepository { requestKey: String, meta: MetaDetails, fallbackItemId: String, + fallbackItemType: String, mdbListSettings: com.nuvio.app.features.mdblist.MdbListSettings, metaScreenSettingsFingerprint: String, ) { val cachedEntry = CachedMetaEntry(baseMeta = meta) cachedMetaByRequestKey[requestKey] = cachedEntry - if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) { + if (!shouldEnrichForMetaScreen(meta, fallbackItemId, mdbListSettings)) { _uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter()) activeRequestKey = requestKey return @@ -321,6 +331,7 @@ object MetaDetailsRepository { requestKey = requestKey, meta = meta, fallbackItemId = fallbackItemId, + fallbackItemType = fallbackItemType, settings = mdbListSettings, settingsFingerprint = metaScreenSettingsFingerprint, ) @@ -337,16 +348,22 @@ object MetaDetailsRepository { requestKey: String, meta: MetaDetails, fallbackItemId: String, + fallbackItemType: String, settings: com.nuvio.app.features.mdblist.MdbListSettings, settingsFingerprint: String, ): MetaDetails { - val enrichedMeta = withTimeoutOrNull(MDBLIST_ENRICH_TIMEOUT_MS) { + val mdbListEnrichedMeta = withTimeoutOrNull(MDBLIST_ENRICH_TIMEOUT_MS) { MdbListMetadataService.enrichMeta( meta = meta, fallbackItemId = fallbackItemId, settings = settings, ) } ?: meta + val enrichedMeta = applyMoreLikeThisSource( + meta = mdbListEnrichedMeta, + fallbackItemId = fallbackItemId, + fallbackItemType = fallbackItemType, + ) cachedMetaByRequestKey[requestKey] = cachedMetaByRequestKey[requestKey] ?.copy( @@ -362,6 +379,49 @@ object MetaDetailsRepository { return enrichedMeta } + private suspend fun applyMoreLikeThisSource( + meta: MetaDetails, + fallbackItemId: String, + fallbackItemType: String, + ): MetaDetails { + TraktSettingsRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded() + TmdbSettingsRepository.ensureLoaded() + + val traktSettings = TraktSettingsRepository.uiState.value + val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED + val shouldUseTrakt = shouldUseTraktMoreLikeThis( + isAuthenticated = isTraktAuthenticated, + source = traktSettings.moreLikeThisSource, + ) && supportsMoreLikeThis(meta, fallbackItemType) + + if (shouldUseTrakt) { + val items = runCatching { + TraktRelatedRepository.getRelated( + meta = meta, + fallbackItemId = fallbackItemId, + fallbackItemType = fallbackItemType, + ) + }.onFailure { error -> + log.w { "Failed to load Trakt related titles for ${meta.id}: ${error.message}" } + }.getOrDefault(emptyList()) + + return meta.copy( + moreLikeThis = items, + moreLikeThisSource = MoreLikeThisSource.TRAKT.takeIf { items.isNotEmpty() }, + ) + } + + val tmdbSettings = TmdbSettingsRepository.snapshot() + if (!tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis) { + return meta.copy(moreLikeThis = emptyList(), moreLikeThisSource = null) + } + + return meta.copy( + moreLikeThisSource = MoreLikeThisSource.TMDB.takeIf { meta.moreLikeThis.isNotEmpty() }, + ) + } + private fun shouldFetchMdbListOnMetaScreen( meta: MetaDetails, fallbackItemId: String, @@ -372,18 +432,63 @@ object MetaDetailsRepository { settings = settings, ) + private fun shouldEnrichForMetaScreen( + meta: MetaDetails, + fallbackItemId: String, + settings: com.nuvio.app.features.mdblist.MdbListSettings, + ): Boolean { + if (shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, settings)) return true + return shouldApplyMoreLikeThisSource(meta) + } + + private fun shouldApplyMoreLikeThisSource(meta: MetaDetails): Boolean { + TraktSettingsRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded() + TmdbSettingsRepository.ensureLoaded() + + val traktSettings = TraktSettingsRepository.uiState.value + val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED + val tmdbSettings = TmdbSettingsRepository.snapshot() + return shouldUseTraktMoreLikeThis( + isAuthenticated = isTraktAuthenticated, + source = traktSettings.moreLikeThisSource, + ) || !tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis || meta.moreLikeThisSource == null && meta.moreLikeThis.isNotEmpty() + } + private fun buildMetaScreenSettingsFingerprint( settings: com.nuvio.app.features.mdblist.MdbListSettings, ): String { + TraktSettingsRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded() + TmdbSettingsRepository.ensureLoaded() val providers = settings.enabledProvidersInPriorityOrder().joinToString(",") - return "${settings.enabled}:${settings.apiKey.trim()}:$providers" + val traktSettings = TraktSettingsRepository.uiState.value + val traktAuthMode = TraktAuthRepository.uiState.value.mode + val tmdbSettings = TmdbSettingsRepository.snapshot() + return buildString { + append("${settings.enabled}:${settings.apiKey.trim()}:$providers") + append("|more_like=${traktSettings.moreLikeThisSource}:$traktAuthMode") + append("|tmdb=${tmdbSettings.enabled}:${tmdbSettings.useMoreLikeThis}:${tmdbSettings.hasApiKey}:${tmdbSettings.language}") + } } + private fun supportsMoreLikeThis(meta: MetaDetails, fallbackItemType: String): Boolean = + normalizeMoreLikeThisType(meta.type) != null || normalizeMoreLikeThisType(fallbackItemType) != null + + private fun normalizeMoreLikeThisType(value: String?): String? = + when (value?.trim()?.lowercase()) { + "movie", "film" -> "movie" + "series", "show", "tv", "tvshow" -> "series" + else -> null + } + private fun MetaDetails.withUnreleasedFilter(): MetaDetails { if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this val todayIsoDate = CurrentDateProvider.todayIsoDate() + val releasedMoreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate) return copy( - moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate), + moreLikeThis = releasedMoreLikeThis, + moreLikeThisSource = moreLikeThisSource.takeIf { releasedMoreLikeThis.isNotEmpty() }, collectionItems = collectionItems.filterReleasedItems(todayIsoDate), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 18c1b4b3c..1e48bb62d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -89,6 +89,7 @@ import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.streams.AddonStreamWarmupRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy +import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktCommentReview @@ -96,6 +97,7 @@ import com.nuvio.app.features.trakt.TraktCommentsRepository import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trailer.TrailerPlaybackResolver import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.watched.WatchedRepository @@ -140,6 +142,14 @@ fun MetaDetailsScreen( TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState }.collectAsStateWithLifecycle() + val traktSettingsUiState by remember { + TraktSettingsRepository.ensureLoaded() + TraktSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val tmdbSettingsUiState by remember { + TmdbSettingsRepository.ensureLoaded() + TmdbSettingsRepository.uiState + }.collectAsStateWithLifecycle() val libraryUiState by remember { LibraryRepository.ensureLoaded() LibraryRepository.uiState @@ -237,6 +247,22 @@ fun MetaDetailsScreen( } } + LaunchedEffect( + type, + id, + displayedMeta?.id, + uiState.isLoading, + traktSettingsUiState.moreLikeThisSource, + traktAuthUiState.mode, + tmdbSettingsUiState.enabled, + tmdbSettingsUiState.useMoreLikeThis, + tmdbSettingsUiState.language, + ) { + if (displayedMeta != null && !uiState.isLoading) { + MetaDetailsRepository.load(type, id) + } + } + LaunchedEffect(networkStatusUiState.condition, displayedMeta, uiState.isLoading, type, id) { when (networkStatusUiState.condition) { NetworkCondition.NoInternet, @@ -1417,11 +1443,17 @@ private fun ConfiguredMetaSections( } MetaScreenSectionKey.MORE_LIKE_THIS -> { if (hasMoreLikeThisSection) { + val sourceLabel = when (meta.moreLikeThisSource) { + MoreLikeThisSource.TMDB -> stringResource(Res.string.detail_more_like_this_powered_by_tmdb) + MoreLikeThisSource.TRAKT -> stringResource(Res.string.detail_more_like_this_powered_by_trakt) + null -> null + } DetailPosterRailSection( title = stringResource(Res.string.details_more_like_this), items = meta.moreLikeThis, watchedKeys = watchedKeys, showHeader = showHeader, + sourceLabel = sourceLabel, onPosterClick = onOpenMeta, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt index 856f442c3..58c2269f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt @@ -1,8 +1,15 @@ package com.nuvio.app.features.details.components import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.NuvioShelfSection @@ -19,28 +26,45 @@ fun DetailPosterRailSection( modifier: Modifier = Modifier, showHeader: Boolean = true, headerHorizontalPadding: Dp = 0.dp, + sourceLabel: String? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, ) { if (items.isEmpty()) return - NuvioShelfSection( - title = if (showHeader) title else "", - entries = items, - modifier = modifier, - headerHorizontalPadding = headerHorizontalPadding, - rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding), - showHeaderAccent = false, - key = { item -> item.stableKey() }, - ) { item -> - HomePosterCard( - item = item, - isWatched = WatchingState.isPosterWatched( - watchedKeys = watchedKeys, + Column(modifier = modifier.fillMaxWidth()) { + NuvioShelfSection( + title = if (showHeader) title else "", + entries = items, + headerHorizontalPadding = headerHorizontalPadding, + rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding), + showHeaderAccent = false, + key = { item -> item.stableKey() }, + ) { item -> + HomePosterCard( item = item, - ), - onClick = onPosterClick?.let { { it(item) } }, - onLongClick = onPosterLongClick?.let { { it(item) } }, - ) + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = item, + ), + onClick = onPosterClick?.let { { it(item) } }, + onLongClick = onPosterLongClick?.let { { it(item) } }, + ) + } + + sourceLabel + ?.takeIf { it.isNotBlank() } + ?.let { label -> + Text( + text = label, + modifier = Modifier + .align(Alignment.End) + .padding(end = headerHorizontalPadding, top = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index d98da5120..796cca623 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -809,6 +809,7 @@ internal fun settingsSearchEntries( PlaybackSearchRow("trakt-watch-progress", stringResource(Res.string.trakt_watch_progress_title), stringResource(Res.string.trakt_watch_progress_subtitle)), PlaybackSearchRow("trakt-continue-watching-window", stringResource(Res.string.trakt_continue_watching_window), stringResource(Res.string.trakt_continue_watching_subtitle)), PlaybackSearchRow("trakt-comments", stringResource(Res.string.settings_trakt_comments), stringResource(Res.string.settings_trakt_comments_description)), + PlaybackSearchRow("trakt-more-like-this-source", stringResource(Res.string.trakt_more_like_this_source_title), stringResource(Res.string.trakt_more_like_this_source_subtitle)), ).forEach { row -> addRow( page = SettingsPage.TraktAuthentication, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 198b31238..ac9815580 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -43,6 +43,7 @@ import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions +import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.TraktSettingsUiState import com.nuvio.app.features.trakt.WatchProgressSource @@ -82,6 +83,12 @@ import nuvio.composeapp.generated.resources.trakt_library_source_subtitle import nuvio.composeapp.generated.resources.trakt_library_source_title import nuvio.composeapp.generated.resources.trakt_library_source_trakt import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_title +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_subtitle +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_title +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_tmdb +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_trakt import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected @@ -148,11 +155,13 @@ private fun TraktFeatureRows( var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) } var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } + var showMoreLikeThisSourceDialog by rememberSaveable { mutableStateOf(false) } var statusMessage by rememberSaveable { mutableStateOf(null) } val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode) val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) + val moreLikeThisSourceValue = moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource) val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected) @@ -189,6 +198,14 @@ private fun TraktFeatureRows( isTablet = isTablet, onCheckedChange = onCommentsEnabledChange, ) + SettingsGroupDivider(isTablet = isTablet) + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_more_like_this_source_title), + description = stringResource(Res.string.trakt_more_like_this_source_subtitle), + value = moreLikeThisSourceValue, + isTablet = isTablet, + onClick = { showMoreLikeThisSourceDialog = true }, + ) statusMessage?.takeIf { it.isNotBlank() }?.let { message -> SettingsGroupDivider(isTablet = isTablet) TraktInfoRow( @@ -239,6 +256,17 @@ private fun TraktFeatureRows( onDismiss = { showContinueWatchingWindowDialog = false }, ) } + + if (showMoreLikeThisSourceDialog) { + MoreLikeThisSourceDialog( + selectedSource = settingsUiState.moreLikeThisSource, + onSourceSelected = { source -> + TraktSettingsRepository.setMoreLikeThisSource(source) + showMoreLikeThisSourceDialog = false + }, + onDismiss = { showMoreLikeThisSourceDialog = false }, + ) + } } @Composable @@ -322,6 +350,13 @@ private fun watchProgressSourceLabel(source: WatchProgressSource): String = WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) } +@Composable +private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String = + when (source) { + MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt) + MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb) + } + @Composable private fun continueWatchingDaysCapLabel(daysCap: Int): String { val normalized = normalizeTraktContinueWatchingDaysCap(daysCap) @@ -494,6 +529,59 @@ private fun ContinueWatchingWindowDialog( } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun MoreLikeThisSourceDialog( + selectedSource: MoreLikeThisSourcePreference, + onSourceSelected: (MoreLikeThisSourcePreference) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_more_like_this_source_dialog_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.TMDB).forEach { source -> + TraktDialogOption( + label = moreLikeThisSourceLabel(source), + selected = source == selectedSource, + onClick = { onSourceSelected(source) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun TraktDialogOption( label: String, 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 index 0a29dd1be..dd690f741 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -7,6 +7,7 @@ import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.MetaTrailer import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.details.MoreLikeThisSource import com.nuvio.app.features.details.PersonDetail import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape @@ -618,6 +619,7 @@ object TmdbMetadataService { country = enrichment.countries.takeIf { it.isNotEmpty() }?.joinToString(", "), language = enrichment.language, moreLikeThis = enrichment.moreLikeThis, + moreLikeThisSource = MoreLikeThisSource.TMDB.takeIf { enrichment.moreLikeThis.isNotEmpty() }, collectionName = enrichment.collectionName, collectionItems = enrichment.collectionItems, trailers = enrichment.trailers, @@ -726,7 +728,10 @@ object TmdbMetadataService { } if (enrichment != null && settings.useMoreLikeThis) { - updated = updated.copy(moreLikeThis = enrichment.moreLikeThis) + updated = updated.copy( + moreLikeThis = enrichment.moreLikeThis, + moreLikeThisSource = MoreLikeThisSource.TMDB.takeIf { enrichment.moreLikeThis.isNotEmpty() }, + ) } if (enrichment != null && settings.useCollections) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt new file mode 100644 index 000000000..c0b61befb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt @@ -0,0 +1,327 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.tmdb.TmdbService +import io.ktor.http.encodeURLParameter +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.math.roundToInt + +private const val BASE_URL = "https://api.trakt.tv" +private const val RELATED_LIMIT = 20 +private const val RELATED_CACHE_TTL_MS = 10 * 60_000L + +object TraktRelatedRepository { + private val log = Logger.withTag("TraktRelated") + private val json = Json { ignoreUnknownKeys = true } + private val cacheMutex = Mutex() + private val cache = mutableMapOf() + + suspend fun getRelated( + meta: MetaDetails, + fallbackItemId: String? = null, + fallbackItemType: String? = null, + forceRefresh: Boolean = false, + ): List { + val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList() + val target = resolveRelatedTarget( + meta = meta, + fallbackItemId = fallbackItemId, + fallbackItemType = fallbackItemType, + headers = headers, + ) ?: return emptyList() + val cacheKey = "${target.type.apiValue}|${target.pathId}" + + if (forceRefresh) { + cacheMutex.withLock { cache.remove(cacheKey) } + } + + if (!forceRefresh) { + cacheMutex.withLock { + cache[cacheKey]?.let { cached -> + if (TraktPlatformClock.nowEpochMs() - cached.updatedAtMs <= RELATED_CACHE_TTL_MS) { + return cached.items + } + } + } + } + + val items = fetchRelated(target = target, headers = headers) + .distinctBy { it.stableRelatedKey() } + .take(RELATED_LIMIT) + + cacheMutex.withLock { + cache[cacheKey] = TimedCache( + items = items, + updatedAtMs = TraktPlatformClock.nowEpochMs(), + ) + } + return items + } + + fun clearCache() { + cache.clear() + } + + private suspend fun fetchRelated( + target: ResolvedRelatedTarget, + headers: Map, + ): List { + val endpoint = when (target.type) { + TraktRelatedType.MOVIE -> "movies" + TraktRelatedType.SHOW -> "shows" + } + val response = httpRequestRaw( + method = "GET", + url = buildTraktUrl("$endpoint/${target.pathId}/related", mapOf("extended" to "full,images")), + headers = jsonHeaders(headers), + body = "", + ) + + if (response.status == 404) return emptyList() + if (response.status !in 200..299) { + error("Failed to load Trakt related titles (${response.status})") + } + + return when (target.type) { + TraktRelatedType.MOVIE -> json.decodeFromString>(response.body) + .mapNotNull { it.toMetaPreview() } + TraktRelatedType.SHOW -> json.decodeFromString>(response.body) + .mapNotNull { it.toMetaPreview() } + } + } + + private suspend fun resolveRelatedTarget( + meta: MetaDetails, + fallbackItemId: String?, + fallbackItemType: String?, + headers: Map, + ): ResolvedRelatedTarget? { + val type = resolveRelatedType(meta = meta, fallbackItemType = fallbackItemType) ?: return null + resolveDirectPathId(meta.id)?.let { return ResolvedRelatedTarget(type, it) } + resolveDirectPathId(fallbackItemId)?.let { return ResolvedRelatedTarget(type, it) } + + val tmdbId = resolveTmdbCandidate(meta.id) + ?: resolveTmdbCandidate(fallbackItemId) + ?: TmdbService.ensureTmdbId(meta.id, meta.type)?.toIntOrNull() + ?: fallbackItemId?.let { TmdbService.ensureTmdbId(it, fallbackItemType ?: meta.type) }?.toIntOrNull() + ?: return null + + return resolveViaTraktSearch(type = type, tmdbId = tmdbId, headers = headers) + } + + private fun resolveRelatedType(meta: MetaDetails, fallbackItemType: String?): TraktRelatedType? { + return when (normalizeRelatedType(meta.type)) { + TraktRelatedType.MOVIE -> TraktRelatedType.MOVIE + TraktRelatedType.SHOW -> TraktRelatedType.SHOW + null -> normalizeRelatedType(fallbackItemType) + } + } + + private fun normalizeRelatedType(value: String?): TraktRelatedType? = + when (value?.trim()?.lowercase()) { + "movie", "film" -> TraktRelatedType.MOVIE + "series", "show", "tv", "tvshow" -> TraktRelatedType.SHOW + else -> null + } + + private fun resolveDirectPathId(value: String?): String? { + val raw = value?.trim().orEmpty() + if (raw.isBlank()) return null + extractImdbId(raw)?.let { return it } + parseTraktContentIds(raw).trakt?.let { return it.toString() } + return raw + .takeIf { !it.startsWith("tmdb:", ignoreCase = true) } + ?.takeIf { it.all(Char::isDigit) } + } + + private fun resolveTmdbCandidate(value: String?): Int? = + parseTraktContentIds(value).tmdb ?: extractTmdbId(value) + + private suspend fun resolveViaTraktSearch( + type: TraktRelatedType, + tmdbId: Int, + headers: Map, + ): ResolvedRelatedTarget? { + val response = runCatching { + httpRequestRaw( + method = "GET", + url = buildTraktUrl( + endpoint = "search/tmdb/$tmdbId", + query = mapOf("type" to type.apiValue), + ), + headers = jsonHeaders(headers), + body = "", + ) + }.onFailure { error -> + log.w(error) { "TMDB to Trakt lookup failed for tmdbId=$tmdbId" } + }.getOrNull() ?: return null + + if (response.status == 404) return null + if (response.status !in 200..299) { + log.w { "Failed to resolve Trakt id for tmdbId=$tmdbId (${response.status})" } + return null + } + + val results = runCatching { + json.decodeFromString>(response.body) + }.getOrDefault(emptyList()) + val match = results.firstOrNull { it.type.equals(type.apiValue, ignoreCase = true) } + val ids = when (type) { + TraktRelatedType.MOVIE -> match?.movie?.ids + TraktRelatedType.SHOW -> match?.show?.ids + } + return ids?.bestPathId()?.let { ResolvedRelatedTarget(type, it) } + } + + private fun buildTraktUrl(endpoint: String, query: Map = emptyMap()): String { + val queryString = (query + mapOf("page" to "1", "limit" to RELATED_LIMIT.toString())) + .entries + .filter { (_, value) -> value.isNotBlank() } + .joinToString("&") { (key, value) -> + "${key.encodeURLParameter()}=${value.encodeURLParameter()}" + } + return "$BASE_URL/${endpoint.trim('/')}" + if (queryString.isBlank()) "" else "?$queryString" + } + + private fun jsonHeaders(headers: Map): Map = + mapOf("Accept" to "application/json") + headers +} + +private data class TimedCache( + val items: List, + val updatedAtMs: Long, +) + +private enum class TraktRelatedType(val apiValue: String) { + MOVIE("movie"), + SHOW("show"), +} + +private data class ResolvedRelatedTarget( + val type: TraktRelatedType, + val pathId: String, +) + +@Serializable +private data class TraktRelatedSearchResultDto( + val type: String? = null, + val movie: TraktRelatedSearchItemDto? = null, + val show: TraktRelatedSearchItemDto? = null, +) + +@Serializable +private data class TraktRelatedSearchItemDto( + val ids: TraktExternalIds? = null, +) + +@Serializable +private data class TraktRelatedMovieDto( + val title: String? = null, + @SerialName("original_title") val originalTitle: String? = null, + val year: Int? = null, + val ids: TraktExternalIds? = null, + val overview: String? = null, + val released: String? = null, + val rating: Double? = null, + val genres: List? = null, + val images: TraktImagesDto? = null, +) + +@Serializable +private data class TraktRelatedShowDto( + val title: String? = null, + @SerialName("original_title") val originalTitle: String? = null, + val year: Int? = null, + val ids: TraktExternalIds? = null, + val overview: String? = null, + @SerialName("first_aired") val firstAired: String? = null, + val rating: Double? = null, + val genres: List? = null, + val images: TraktImagesDto? = null, +) + +private fun TraktRelatedMovieDto.toMetaPreview(): MetaPreview? { + val normalizedTitle = title?.trim()?.takeIf(String::isNotBlank) + ?: originalTitle?.trim()?.takeIf(String::isNotBlank) + ?: return null + val contentId = normalizeTraktContentId(ids, fallback = fallbackTraktContentId(ids, "movie")) + if (contentId.isBlank()) return null + + return MetaPreview( + id = contentId, + type = "movie", + name = normalizedTitle, + poster = images.traktBestPosterUrl(), + banner = images.traktBestBackdropUrl(), + logo = images.traktBestLogoUrl(), + posterShape = PosterShape.Poster, + description = overview?.trim()?.takeIf(String::isNotBlank), + releaseInfo = year?.toString() ?: released?.take(4), + rawReleaseDate = released, + imdbRating = rating?.formatTraktRating(), + genres = genres.orEmpty(), + ) +} + +private fun TraktRelatedShowDto.toMetaPreview(): MetaPreview? { + val normalizedTitle = title?.trim()?.takeIf(String::isNotBlank) + ?: originalTitle?.trim()?.takeIf(String::isNotBlank) + ?: return null + val contentId = normalizeTraktContentId(ids, fallback = fallbackTraktContentId(ids, "series")) + if (contentId.isBlank()) return null + + return MetaPreview( + id = contentId, + type = "series", + name = normalizedTitle, + poster = images.traktBestPosterUrl(), + banner = images.traktBestBackdropUrl(), + logo = images.traktBestLogoUrl(), + posterShape = PosterShape.Poster, + description = overview?.trim()?.takeIf(String::isNotBlank), + releaseInfo = year?.toString() ?: firstAired?.take(4), + rawReleaseDate = firstAired, + imdbRating = rating?.formatTraktRating(), + genres = genres.orEmpty(), + ) +} + +private fun fallbackTraktContentId(ids: TraktExternalIds?, typePrefix: String): String? = + ids?.slug?.takeIf { it.isNotBlank() }?.let { "$typePrefix:$it" } + ?: ids?.trakt?.let { "trakt:$it" } + +private fun TraktExternalIds.bestPathId(): String? = + imdb?.takeIf { it.isNotBlank() } + ?: trakt?.toString() + ?: slug?.takeIf { it.isNotBlank() } + +private fun MetaPreview.stableRelatedKey(): String = "$type:$id" + +private fun extractImdbId(value: String?): String? = + value + ?.trim() + ?.split(':', '/', '?', '&') + ?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) } + ?.takeIf { it.length > 2 } + +private fun extractTmdbId(value: String?): Int? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isBlank()) return null + return trimmed + .takeIf { it.startsWith("tmdb:", ignoreCase = true) } + ?.substringAfter(':') + ?.substringBefore(':') + ?.substringBefore('/') + ?.toIntOrNull() +} + +private fun Double.formatTraktRating(): String = + ((this * 10).roundToInt() / 10.0).toString() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt index ee9cccd49..3b22d9dc3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -41,10 +41,24 @@ val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE +@Serializable +enum class MoreLikeThisSourcePreference { + TRAKT, + TMDB; + + companion object { + fun fromStorage(value: String?): MoreLikeThisSourcePreference = + entries.firstOrNull { it.name == value } ?: DEFAULT_MORE_LIKE_THIS_SOURCE + } +} + +val DEFAULT_MORE_LIKE_THIS_SOURCE: MoreLikeThisSourcePreference = MoreLikeThisSourcePreference.TRAKT + data class TraktSettingsUiState( val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE, val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, val librarySourceMode: LibrarySourceMode = DEFAULT_LIBRARY_SOURCE_MODE, + val moreLikeThisSource: MoreLikeThisSourcePreference = DEFAULT_MORE_LIKE_THIS_SOURCE, ) @Serializable @@ -52,6 +66,7 @@ private data class StoredTraktSettings( val watchProgressSource: String? = null, val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, val librarySourceMode: String? = null, + val moreLikeThisSource: String? = null, ) object TraktSettingsRepository { @@ -101,6 +116,13 @@ object TraktSettingsRepository { persist() } + fun setMoreLikeThisSource(source: MoreLikeThisSourcePreference) { + ensureLoaded() + if (_uiState.value.moreLikeThisSource == source) return + _uiState.value = _uiState.value.copy(moreLikeThisSource = source) + persist() + } + private fun loadFromDisk() { hasLoaded = true @@ -119,6 +141,7 @@ object TraktSettingsRepository { watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource), continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap), librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode), + moreLikeThisSource = MoreLikeThisSourcePreference.fromStorage(stored.moreLikeThisSource), ) } else { TraktSettingsUiState() @@ -132,6 +155,7 @@ object TraktSettingsRepository { watchProgressSource = _uiState.value.watchProgressSource.name, continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap, librarySourceMode = _uiState.value.librarySourceMode.name, + moreLikeThisSource = _uiState.value.moreLikeThisSource.name, ), ), ) @@ -164,3 +188,8 @@ fun shouldUseTraktLibrary( isAuthenticated: Boolean, source: LibrarySourceMode, ): Boolean = effectiveLibrarySourceMode(isAuthenticated, source) == LibrarySourceMode.TRAKT + +fun shouldUseTraktMoreLikeThis( + isAuthenticated: Boolean, + source: MoreLikeThisSourcePreference, +): Boolean = isAuthenticated && source == MoreLikeThisSourcePreference.TRAKT diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt index f504fcc8e..c051244ae 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt @@ -34,6 +34,19 @@ class TraktSettingsRepositoryTest { assertEquals(LibrarySourceMode.LOCAL, librarySourceModeFromStorage("LOCAL")) } + @Test + fun `more like this source defaults to Trakt for unset or invalid storage`() { + assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage(null)) + assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage("")) + assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage("not-a-source")) + } + + @Test + fun `more like this source restores valid storage values`() { + assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage("TRAKT")) + assertEquals(MoreLikeThisSourcePreference.TMDB, MoreLikeThisSourcePreference.fromStorage("TMDB")) + } + @Test fun `continue watching cap normalizes finite windows and all history`() { assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0)) @@ -64,4 +77,26 @@ class TraktSettingsRepositoryTest { effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.TRAKT), ) } + + @Test + fun `Trakt more like this is active only when authenticated and selected`() { + assertFalse( + shouldUseTraktMoreLikeThis( + isAuthenticated = false, + source = MoreLikeThisSourcePreference.TRAKT, + ), + ) + assertFalse( + shouldUseTraktMoreLikeThis( + isAuthenticated = true, + source = MoreLikeThisSourcePreference.TMDB, + ), + ) + assertTrue( + shouldUseTraktMoreLikeThis( + isAuthenticated = true, + source = MoreLikeThisSourcePreference.TRAKT, + ), + ) + } }