feat: trakt as a source for morelikethis

This commit is contained in:
tapframe 2026-06-06 23:56:23 +05:30
parent ab5415f477
commit 9b3298fe2c
11 changed files with 683 additions and 23 deletions

View file

@ -1008,6 +1008,12 @@
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
<string name="trakt_watch_progress_trakt_selected">Watch progress source set to Trakt</string>
<string name="trakt_watch_progress_nuvio_selected">Watch progress source set to Nuvio Sync</string>
<string name="trakt_more_like_this_source_title">More Like This source</string>
<string name="trakt_more_like_this_source_subtitle">Choose where recommendations come from on detail pages</string>
<string name="trakt_more_like_this_source_dialog_title">More Like This source</string>
<string name="trakt_more_like_this_source_dialog_subtitle">Select the source for recommendations shown on detail pages.</string>
<string name="trakt_more_like_this_source_trakt">Trakt</string>
<string name="trakt_more_like_this_source_tmdb">TMDB</string>
<string name="trakt_continue_watching_window">Continue Watching Window</string>
<string name="trakt_continue_watching_subtitle">Trakt history considered for continue watching</string>
<string name="trakt_cw_window_title">Continue Watching Window</string>
@ -1133,6 +1139,8 @@
<string name="details_director">Director</string>
<string name="details_failed_to_load">Failed to load</string>
<string name="details_more_like_this">More Like This</string>
<string name="detail_more_like_this_powered_by_tmdb">Powered by TMDB</string>
<string name="detail_more_like_this_powered_by_trakt">Powered by Trakt</string>
<string name="details_seasons">Seasons</string>
<string name="details_series_missing_numbers">This addon returned videos for the series, but none included season or episode numbers.</string>
<string name="details_series_no_metadata">This addon did not provide episode metadata for this series.</string>

View file

@ -31,6 +31,7 @@ data class MetaDetails(
val website: String? = null,
val hasScheduledVideos: Boolean = false,
val moreLikeThis: List<MetaPreview> = emptyList(),
val moreLikeThisSource: MoreLikeThisSource? = null,
val collectionName: String? = null,
val collectionItems: List<MetaPreview> = emptyList(),
val trailers: List<MetaTrailer> = emptyList(),
@ -38,6 +39,11 @@ data class MetaDetails(
val videos: List<MetaVideo> = emptyList(),
)
enum class MoreLikeThisSource {
TMDB,
TRAKT,
}
data class MetaExternalRating(
val source: String,
val value: Double,

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String?>(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,

View file

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

View file

@ -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<String, TimedCache>()
suspend fun getRelated(
meta: MetaDetails,
fallbackItemId: String? = null,
fallbackItemType: String? = null,
forceRefresh: Boolean = false,
): List<MetaPreview> {
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<String, String>,
): List<MetaPreview> {
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<List<TraktRelatedMovieDto>>(response.body)
.mapNotNull { it.toMetaPreview() }
TraktRelatedType.SHOW -> json.decodeFromString<List<TraktRelatedShowDto>>(response.body)
.mapNotNull { it.toMetaPreview() }
}
}
private suspend fun resolveRelatedTarget(
meta: MetaDetails,
fallbackItemId: String?,
fallbackItemType: String?,
headers: Map<String, String>,
): 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<String, String>,
): 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<List<TraktRelatedSearchResultDto>>(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<String, String> = 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<String, String>): Map<String, String> =
mapOf("Accept" to "application/json") + headers
}
private data class TimedCache(
val items: List<MetaPreview>,
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<String>? = 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<String>? = 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()

View file

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

View file

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