mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-03 18:16:15 +00:00
feat: trakt as a source for morelikethis
This commit is contained in:
parent
ab5415f477
commit
9b3298fe2c
11 changed files with 683 additions and 23 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue