Merge branch 'parity' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-06 16:01:49 +05:30
commit 217c1803c7
18 changed files with 301 additions and 33 deletions

View file

@ -475,6 +475,8 @@
<string name="settings_homescreen_selected_count">%1$d of %2$d selected</string> <string name="settings_homescreen_selected_count">%1$d of %2$d selected</string>
<string name="settings_homescreen_show_hero">Show Hero Section</string> <string name="settings_homescreen_show_hero">Show Hero Section</string>
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string> <string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string>
<string name="layout_hide_unreleased">Hide Unreleased Content</string>
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
<string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string> <string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string>
<string name="settings_homescreen_summary_hint">Open a catalog only when you need to rename or reorder it.</string> <string name="settings_homescreen_summary_hint">Open a catalog only when you need to rename or reorder it.</string>
<string name="settings_homescreen_visible">Visible</string> <string name="settings_homescreen_visible">Visible</string>

View file

@ -2,6 +2,9 @@ package com.nuvio.app.features.catalog
import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toMetaPreview import com.nuvio.app.features.library.toMetaPreview
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.filterReleasedItems
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -124,7 +127,7 @@ object CatalogRepository {
catalogId = request.catalogId, catalogId = request.catalogId,
genre = request.genre, genre = request.genre,
skip = requestedSkip.takeIf { it > 0 }, skip = requestedSkip.takeIf { it > 0 },
) ).withUnreleasedFilter()
}.fold( }.fold(
onSuccess = { page -> onSuccess = { page ->
if (activeRequest != request) return@fold if (activeRequest != request) return@fold
@ -158,6 +161,12 @@ object CatalogRepository {
} }
} }
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
}
private data class CatalogRequest( private data class CatalogRequest(
val manifestUrl: String, val manifestUrl: String,
val type: String, val type: String,

View file

@ -52,6 +52,7 @@ import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.stableKey
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -74,20 +75,21 @@ fun CatalogScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle() val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
val posterCardStyle = rememberPosterCardStyleUiState() val posterCardStyle = rememberPosterCardStyleUiState()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
var headerHeightPx by remember { mutableIntStateOf(0) } var headerHeightPx by remember { mutableIntStateOf(0) }
var observedOfflineState by remember { mutableStateOf(false) } var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) { LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
CatalogRepository.load( CatalogRepository.load(
manifestUrl = manifestUrl, manifestUrl = manifestUrl,
type = type, type = type,
catalogId = catalogId, catalogId = catalogId,
genre = genre, genre = genre,
supportsPagination = supportsPagination, supportsPagination = supportsPagination,
force = false, force = true,
) )
} }

View file

@ -3,14 +3,18 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
import com.nuvio.app.features.catalog.CatalogPage
import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.supportsPagination import com.nuvio.app.features.catalog.supportsPagination
import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.filterReleasedItems
import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.stableKey
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -320,7 +324,7 @@ object FolderDetailRepository {
genre = currentTab.genre, genre = currentTab.genre,
skip = requestedSkip.takeIf { it > 0 }, skip = requestedSkip.takeIf { it > 0 },
) )
} }.withUnreleasedFilter()
}.onSuccess { page -> }.onSuccess { page ->
updateTab(index) { tab -> updateTab(index) { tab ->
val mergedItems = if (reset) { val mergedItems = if (reset) {
@ -418,6 +422,12 @@ object FolderDetailRepository {
private fun Boolean?.orFalse(): Boolean = this == true private fun Boolean?.orFalse(): Boolean = this == true
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
}
private fun tmdbCatalogId(source: CollectionSource): String = private fun tmdbCatalogId(source: CollectionSource): String =
buildString { buildString {
append("tmdb_") append("tmdb_")

View file

@ -5,11 +5,14 @@ import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.filterReleasedItems
import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -48,14 +51,14 @@ object MetaDetailsRepository {
cachedEntry.metaScreenMeta cachedEntry.metaScreenMeta
?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint } ?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint }
?.let { cachedMeta -> ?.let { cachedMeta ->
_uiState.value = MetaDetailsUiState(meta = cachedMeta) _uiState.value = MetaDetailsUiState(meta = cachedMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
return return
} }
val cachedBaseMeta = cachedEntry.baseMeta val cachedBaseMeta = cachedEntry.baseMeta
if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) { if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta) _uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
return return
} }
@ -81,7 +84,7 @@ object MetaDetailsRepository {
settingsFingerprint = metaScreenSettingsFingerprint, settingsFingerprint = metaScreenSettingsFingerprint,
) )
} }
_uiState.value = MetaDetailsUiState(meta = enrichedMeta) _uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
} }
return return
@ -302,7 +305,7 @@ object MetaDetailsRepository {
cachedMetaByRequestKey[requestKey] = cachedEntry cachedMetaByRequestKey[requestKey] = cachedEntry
if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) { if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = meta) _uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
return return
} }
@ -324,7 +327,7 @@ object MetaDetailsRepository {
metaScreenMeta = enrichedMeta, metaScreenMeta = enrichedMeta,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
) )
_uiState.value = MetaDetailsUiState(meta = enrichedMeta) _uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter())
activeRequestKey = requestKey activeRequestKey = requestKey
} }
@ -374,6 +377,15 @@ object MetaDetailsRepository {
return "${settings.enabled}:${settings.apiKey.trim()}:$providers" return "${settings.enabled}:${settings.apiKey.trim()}:$providers"
} }
private fun MetaDetails.withUnreleasedFilter(): MetaDetails {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val todayIsoDate = CurrentDateProvider.todayIsoDate()
return copy(
moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate),
collectionItems = collectionItems.filterReleasedItems(todayIsoDate),
)
}
fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> { fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> {
val meta = _uiState.value.meta ?: return emptyList() val meta = _uiState.value.meta ?: return emptyList()

View file

@ -52,6 +52,7 @@ internal object HomeCatalogParser {
posterShape = meta.string("posterShape").toPosterShape(), posterShape = meta.string("posterShape").toPosterShape(),
description = meta.string("description"), description = meta.string("description"),
releaseInfo = meta.string("releaseInfo"), releaseInfo = meta.string("releaseInfo"),
rawReleaseDate = meta.string("released"),
imdbRating = meta.string("imdbRating"), imdbRating = meta.string("imdbRating"),
genres = meta.array("genres").mapNotNull { genre -> genres = meta.array("genres").mapNotNull { genre ->
genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() } genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() }

View file

@ -32,12 +32,15 @@ data class HomeCatalogSettingsItem(
data class HomeCatalogSettingsUiState( data class HomeCatalogSettingsUiState(
val heroEnabled: Boolean = true, val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val items: List<HomeCatalogSettingsItem> = emptyList(), val items: List<HomeCatalogSettingsItem> = emptyList(),
) { ) {
val signature: String val signature: String
get() = buildString { get() = buildString {
append(heroEnabled) append(heroEnabled)
append('|') append('|')
append(hideUnreleasedContent)
append('|')
append( append(
items.joinToString(separator = "|") { item -> items.joinToString(separator = "|") { item ->
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}" "${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
@ -55,6 +58,7 @@ internal data class HomeCatalogPreference(
internal data class HomeCatalogSettingsSnapshot( internal data class HomeCatalogSettingsSnapshot(
val heroEnabled: Boolean, val heroEnabled: Boolean,
val hideUnreleasedContent: Boolean,
val preferences: Map<String, HomeCatalogPreference>, val preferences: Map<String, HomeCatalogPreference>,
) )
@ -70,6 +74,7 @@ private data class StoredHomeCatalogPreference(
@Serializable @Serializable
private data class StoredHomeCatalogSettingsPayload( private data class StoredHomeCatalogSettingsPayload(
val heroEnabled: Boolean = true, val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val items: List<StoredHomeCatalogPreference> = emptyList(), val items: List<StoredHomeCatalogPreference> = emptyList(),
) )
@ -89,11 +94,13 @@ object HomeCatalogSettingsRepository {
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList() private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf() private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
private var heroEnabled = true private var heroEnabled = true
private var hideUnreleasedContent = false
fun onProfileChanged() { fun onProfileChanged() {
hasLoaded = false hasLoaded = false
preferences.clear() preferences.clear()
heroEnabled = true heroEnabled = true
hideUnreleasedContent = false
definitions = emptyList() definitions = emptyList()
collectionDefinitions = emptyList() collectionDefinitions = emptyList()
_uiState.value = HomeCatalogSettingsUiState() _uiState.value = HomeCatalogSettingsUiState()
@ -105,6 +112,7 @@ object HomeCatalogSettingsRepository {
collectionDefinitions = emptyList() collectionDefinitions = emptyList()
preferences.clear() preferences.clear()
heroEnabled = true heroEnabled = true
hideUnreleasedContent = false
_uiState.value = HomeCatalogSettingsUiState() _uiState.value = HomeCatalogSettingsUiState()
} }
@ -135,6 +143,7 @@ object HomeCatalogSettingsRepository {
ensureLoaded() ensureLoaded()
return HomeCatalogSettingsSnapshot( return HomeCatalogSettingsSnapshot(
heroEnabled = heroEnabled, heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
preferences = preferences.mapValues { (_, value) -> preferences = preferences.mapValues { (_, value) ->
HomeCatalogPreference( HomeCatalogPreference(
customTitle = value.customTitle, customTitle = value.customTitle,
@ -154,6 +163,15 @@ object HomeCatalogSettingsRepository {
HomeRepository.applyCurrentSettings() HomeRepository.applyCurrentSettings()
} }
fun setHideUnreleasedContent(enabled: Boolean) {
ensureLoaded()
if (hideUnreleasedContent == enabled) return
hideUnreleasedContent = enabled
publish()
persist()
HomeRepository.applyCurrentSettings()
}
fun setHeroSourceEnabled(key: String, enabled: Boolean) { fun setHeroSourceEnabled(key: String, enabled: Boolean) {
updatePreference(key) { preference -> updatePreference(key) { preference ->
if (!enabled) { if (!enabled) {
@ -181,6 +199,7 @@ object HomeCatalogSettingsRepository {
fun resetToDefaults() { fun resetToDefaults() {
ensureLoaded() ensureLoaded()
heroEnabled = true heroEnabled = true
hideUnreleasedContent = false
preferences.clear() preferences.clear()
normalizePreferences() normalizePreferences()
publish() publish()
@ -226,7 +245,9 @@ object HomeCatalogSettingsRepository {
if (parsedPayload != null) { if (parsedPayload != null) {
heroEnabled = parsedPayload.heroEnabled heroEnabled = parsedPayload.heroEnabled
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap() preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
publish()
return return
} }
@ -235,6 +256,7 @@ object HomeCatalogSettingsRepository {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
preferences = legacyItems.associateBy { it.key }.toMutableMap() preferences = legacyItems.associateBy { it.key }.toMutableMap()
publish()
} }
private fun normalizePreferences() { private fun normalizePreferences() {
@ -322,6 +344,7 @@ object HomeCatalogSettingsRepository {
_uiState.value = HomeCatalogSettingsUiState( _uiState.value = HomeCatalogSettingsUiState(
heroEnabled = heroEnabled, heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
items = items, items = items,
) )
} }
@ -331,6 +354,7 @@ object HomeCatalogSettingsRepository {
json.encodeToString( json.encodeToString(
StoredHomeCatalogSettingsPayload( StoredHomeCatalogSettingsPayload(
heroEnabled = heroEnabled, heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
items = preferences.values.sortedBy { it.order }, items = preferences.values.sortedBy { it.order },
), ),
), ),
@ -411,11 +435,16 @@ object HomeCatalogSettingsRepository {
) )
} }
} }
return SyncHomeCatalogPayload(items = items) return SyncHomeCatalogPayload(
hideUnreleasedContent = hideUnreleasedContent,
items = items,
)
} }
fun applyFromRemote(payload: SyncHomeCatalogPayload) { fun applyFromRemote(payload: SyncHomeCatalogPayload) {
ensureLoaded() ensureLoaded()
hideUnreleasedContent = payload.hideUnreleasedContent
if (payload.items.isNotEmpty()) {
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled } val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
preferences = payload.items.associate { item -> preferences = payload.items.associate { item ->
val key = if (item.isCollection) { val key = if (item.isCollection) {
@ -431,6 +460,7 @@ object HomeCatalogSettingsRepository {
order = item.order, order = item.order,
) )
}.toMutableMap() }.toMutableMap()
}
hasLoaded = true hasLoaded = true
publish() publish()
persist() persist()

View file

@ -41,6 +41,7 @@ data class SyncCatalogItem(
@Serializable @Serializable
data class SyncHomeCatalogPayload( data class SyncHomeCatalogPayload(
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
val items: List<SyncCatalogItem> = emptyList(), val items: List<SyncCatalogItem> = emptyList(),
) )
@ -101,7 +102,10 @@ object HomeCatalogSettingsSyncService {
} }
if (remotePayload.items.isEmpty()) { if (remotePayload.items.isEmpty()) {
log.i { "pullFromServer — remote has empty items, preserving local" } log.i { "pullFromServer — remote has empty items, preserving local catalog order" }
isSyncingFromRemote = true
HomeCatalogSettingsRepository.applyFromRemote(remotePayload)
isSyncingFromRemote = false
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
if (localPayload.items.isNotEmpty()) { if (localPayload.items.isNotEmpty()) {
pushToRemote(profileId) pushToRemote(profileId)

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -145,13 +146,17 @@ object HomeRepository {
) { ) {
val snapshot = HomeCatalogSettingsRepository.snapshot() val snapshot = HomeCatalogSettingsRepository.snapshot()
val preferences = snapshot.preferences val preferences = snapshot.preferences
val todayIsoDate = if (snapshot.hideUnreleasedContent) CurrentDateProvider.todayIsoDate() else null
fun HomeCatalogSection.withReleaseFilter(): HomeCatalogSection =
if (todayIsoDate == null) this else filterReleasedItems(todayIsoDate)
val sections = currentDefinitions val sections = currentDefinitions
.sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE } .sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE }
.mapNotNull { definition -> .mapNotNull { definition ->
val preference = preferences[definition.key] val preference = preferences[definition.key]
if (preference?.enabled == false) return@mapNotNull null if (preference?.enabled == false) return@mapNotNull null
val section = cachedSections[definition.key] ?: return@mapNotNull null val section = cachedSections[definition.key]?.withReleaseFilter() ?: return@mapNotNull null
if (section.items.isEmpty()) return@mapNotNull null if (section.items.isEmpty()) return@mapNotNull null
val customTitle = preference?.customTitle.orEmpty() val customTitle = preference?.customTitle.orEmpty()
section.copy( section.copy(
@ -164,6 +169,7 @@ object HomeRepository {
currentDefinitions currentDefinitions
.filter { definition -> preferences[definition.key]?.heroSourceEnabled != false } .filter { definition -> preferences[definition.key]?.heroSourceEnabled != false }
.mapNotNull { definition -> cachedSections[definition.key] } .mapNotNull { definition -> cachedSections[definition.key] }
.map { section -> section.withReleaseFilter() }
.flatMap { section -> section.items } .flatMap { section -> section.items }
.distinctBy { item -> "${item.type}:${item.id}" } .distinctBy { item -> "${item.type}:${item.id}" }
.shuffled(heroRandom) .shuffled(heroRandom)

View file

@ -0,0 +1,51 @@
package com.nuvio.app.features.home
private val yearRegex = Regex("""\b(19|20)\d{2}\b""")
private val isoDateRegex = Regex("""\d{4}-\d{2}-\d{2}""")
internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean {
rawReleaseDate
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { rawReleased ->
isoCalendarDateOrNull(rawReleased.substringBefore('T'))?.let { releaseDate ->
return releaseDate > todayIsoDate
}
}
val info = releaseInfo ?: return false
isoCalendarDateOrNull(info.trim())?.let { releaseDate ->
return releaseDate > todayIsoDate
}
val releaseYear = yearRegex.find(info)?.value?.toIntOrNull() ?: return false
val currentYear = todayIsoDate.take(4).toIntOrNull() ?: return false
return releaseYear > currentYear
}
internal fun HomeCatalogSection.filterReleasedItems(todayIsoDate: String): HomeCatalogSection {
val filteredItems = items.filterReleasedItems(todayIsoDate)
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
}
internal fun List<MetaPreview>.filterReleasedItems(todayIsoDate: String): List<MetaPreview> =
filterNot { item -> item.isUnreleased(todayIsoDate) }
private fun isoCalendarDateOrNull(value: String?): String? {
val date = value?.trim()?.takeIf { isoDateRegex.matches(it) } ?: return null
val year = date.substring(0, 4).toIntOrNull() ?: return null
val month = date.substring(5, 7).toIntOrNull()?.takeIf { it in 1..12 } ?: return null
val day = date.substring(8, 10).toIntOrNull() ?: return null
if (day !in 1..daysInMonth(year, month)) return null
return date
}
private fun daysInMonth(year: Int, month: Int): Int =
when (month) {
2 -> if (isLeapYear(year)) 29 else 28
4, 6, 9, 11 -> 30
else -> 31
}
private fun isLeapYear(year: Int): Boolean =
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)

View file

@ -5,12 +5,16 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.AddonCatalog
import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.AddonExtraProperty
import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.catalog.CatalogPage
import com.nuvio.app.features.catalog.buildCatalogUrl import com.nuvio.app.features.catalog.buildCatalogUrl
import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.supportsPagination import com.nuvio.app.features.catalog.supportsPagination
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.filterReleasedItems
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -37,6 +41,7 @@ object SearchRepository {
private var activeDiscoverJob: Job? = null private var activeDiscoverJob: Job? = null
private var lastRequestKey: String? = null private var lastRequestKey: String? = null
private var discoverSources: List<DiscoverCatalogOption> = emptyList() private var discoverSources: List<DiscoverCatalogOption> = emptyList()
private var lastDiscoverHideUnreleasedContent: Boolean? = null
fun search(query: String, addons: List<ManagedAddon>) { fun search(query: String, addons: List<ManagedAddon>) {
val normalizedQuery = query.trim() val normalizedQuery = query.trim()
@ -71,6 +76,8 @@ object SearchRepository {
val requestKey = buildString { val requestKey = buildString {
append(normalizedQuery.lowercase()) append(normalizedQuery.lowercase())
append('|') append('|')
append(HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent)
append('|')
append( append(
requests.joinToString(separator = "|") { request -> requests.joinToString(separator = "|") { request ->
"${request.addon.manifestUrl}:${request.type}:${request.catalogId}" "${request.addon.manifestUrl}:${request.type}:${request.catalogId}"
@ -119,6 +126,7 @@ object SearchRepository {
activeDiscoverJob?.cancel() activeDiscoverJob?.cancel()
lastRequestKey = null lastRequestKey = null
discoverSources = emptyList() discoverSources = emptyList()
lastDiscoverHideUnreleasedContent = null
_uiState.value = SearchUiState() _uiState.value = SearchUiState()
_discoverUiState.value = DiscoverUiState() _discoverUiState.value = DiscoverUiState()
} }
@ -128,6 +136,7 @@ object SearchRepository {
if (activeAddons.isEmpty()) { if (activeAddons.isEmpty()) {
activeDiscoverJob?.cancel() activeDiscoverJob?.cancel()
discoverSources = emptyList() discoverSources = emptyList()
lastDiscoverHideUnreleasedContent = null
log.d { "Discover refresh aborted: no active addons" } log.d { "Discover refresh aborted: no active addons" }
_discoverUiState.value = DiscoverUiState( _discoverUiState.value = DiscoverUiState(
emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons, emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons,
@ -137,7 +146,12 @@ object SearchRepository {
val sources = buildDiscoverSources(activeAddons) val sources = buildDiscoverSources(activeAddons)
val current = _discoverUiState.value val current = _discoverUiState.value
if (sources == discoverSources && current.canReuseDiscoverState(sources)) { val hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent
if (
sources == discoverSources &&
lastDiscoverHideUnreleasedContent == hideUnreleasedContent &&
current.canReuseDiscoverState(sources)
) {
log.d { log.d {
"Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " + "Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " +
"genre=${current.selectedGenre ?: "<all>"} items=${current.items.size} nextSkip=${current.nextSkip}" "genre=${current.selectedGenre ?: "<all>"} items=${current.items.size} nextSkip=${current.nextSkip}"
@ -146,6 +160,7 @@ object SearchRepository {
} }
discoverSources = sources discoverSources = sources
lastDiscoverHideUnreleasedContent = hideUnreleasedContent
if (sources.isEmpty()) { if (sources.isEmpty()) {
activeDiscoverJob?.cancel() activeDiscoverJob?.cancel()
log.d { "Discover refresh found no compatible discover catalogs" } log.d { "Discover refresh found no compatible discover catalogs" }
@ -310,7 +325,7 @@ object SearchRepository {
type = type, type = type,
catalogId = catalogId, catalogId = catalogId,
search = query, search = query,
) ).withUnreleasedFilter()
val items = page.items val items = page.items
require(items.isNotEmpty()) { "No search results returned for $catalogName." } require(items.isNotEmpty()) { "No search results returned for $catalogName." }
@ -364,7 +379,7 @@ object SearchRepository {
catalogId = selectedCatalog.catalogId, catalogId = selectedCatalog.catalogId,
genre = current.selectedGenre, genre = current.selectedGenre,
skip = requestedSkip.takeIf { it > 0 }, skip = requestedSkip.takeIf { it > 0 },
) ).withUnreleasedFilter()
}.fold( }.fold(
onSuccess = { page -> onSuccess = { page ->
val latest = _discoverUiState.value val latest = _discoverUiState.value
@ -421,6 +436,12 @@ object SearchRepository {
} }
} }
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
}
private data class SearchCatalogRequest( private data class SearchCatalogRequest(
val addon: ManagedAddon, val addon: ManagedAddon,
val catalogId: String, val catalogId: String,

View file

@ -46,6 +46,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomeEmptyStateCard
@ -88,6 +89,7 @@ fun SearchScreen(
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val uiState by SearchRepository.uiState.collectAsStateWithLifecycle() val uiState by SearchRepository.uiState.collectAsStateWithLifecycle()
val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle() val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle()
val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
@ -123,11 +125,11 @@ fun SearchScreen(
} }
} }
LaunchedEffect(addonRefreshKey) { LaunchedEffect(addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) {
SearchRepository.refreshDiscover(addonsUiState.addons) SearchRepository.refreshDiscover(addonsUiState.addons)
} }
LaunchedEffect(query, addonRefreshKey) { LaunchedEffect(query, addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) {
val normalizedQuery = query.trim() val normalizedQuery = query.trim()
if (normalizedQuery.isBlank()) { if (normalizedQuery.isBlank()) {
lastRequestedQuery = null lastRequestedQuery = null

View file

@ -38,6 +38,8 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomeEmptyStateCard
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_reset import nuvio.composeapp.generated.resources.action_reset
import nuvio.composeapp.generated.resources.layout_hide_unreleased
import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub
import nuvio.composeapp.generated.resources.settings_homescreen_empty_message import nuvio.composeapp.generated.resources.settings_homescreen_empty_message
import nuvio.composeapp.generated.resources.settings_homescreen_empty_title import nuvio.composeapp.generated.resources.settings_homescreen_empty_title
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
@ -62,6 +64,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
internal fun LazyListScope.homescreenSettingsContent( internal fun LazyListScope.homescreenSettingsContent(
isTablet: Boolean, isTablet: Boolean,
heroEnabled: Boolean, heroEnabled: Boolean,
hideUnreleasedContent: Boolean,
items: List<HomeCatalogSettingsItem>, items: List<HomeCatalogSettingsItem>,
) { ) {
val selectedHeroSourceCount = items.count { it.heroSourceEnabled } val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
@ -87,6 +90,14 @@ internal fun LazyListScope.homescreenSettingsContent(
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled, onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled,
) )
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.layout_hide_unreleased),
description = stringResource(Res.string.layout_hide_unreleased_sub),
checked = hideUnreleasedContent,
isTablet = isTablet,
onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent,
)
} }
} }
} }

View file

@ -45,7 +45,10 @@ fun HomescreenSettingsScreen(
} }
} }
} }
val homescreenSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val homescreenSettingsUiState by remember {
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -74,6 +77,7 @@ fun HomescreenSettingsScreen(
homescreenSettingsContent( homescreenSettingsContent(
isTablet = false, isTablet = false,
heroEnabled = homescreenSettingsUiState.heroEnabled, heroEnabled = homescreenSettingsUiState.heroEnabled,
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
items = homescreenSettingsUiState.items, items = homescreenSettingsUiState.items,
) )
} }

View file

@ -135,6 +135,7 @@ fun SettingsScreen(
} }
} }
val homescreenSettingsUiState by remember { val homescreenSettingsUiState by remember {
HomeCatalogSettingsRepository.snapshot()
HomeCatalogSettingsRepository.uiState HomeCatalogSettingsRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val metaScreenSettingsUiState by remember { val metaScreenSettingsUiState by remember {
@ -199,6 +200,7 @@ fun SettingsScreen(
traktCommentsEnabled = traktCommentsEnabled, traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState, traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
homescreenItems = homescreenSettingsUiState.items, homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState, metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
@ -240,6 +242,7 @@ fun SettingsScreen(
traktCommentsEnabled = traktCommentsEnabled, traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState, traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
homescreenItems = homescreenSettingsUiState.items, homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState, metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
@ -291,6 +294,7 @@ private fun MobileSettingsScreen(
traktCommentsEnabled: Boolean, traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState, traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean, homescreenHeroEnabled: Boolean,
homescreenHideUnreleasedContent: Boolean,
homescreenItems: List<HomeCatalogSettingsItem>, homescreenItems: List<HomeCatalogSettingsItem>,
metaScreenSettingsUiState: MetaScreenSettingsUiState, metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
@ -399,6 +403,7 @@ private fun MobileSettingsScreen(
SettingsPage.Homescreen -> homescreenSettingsContent( SettingsPage.Homescreen -> homescreenSettingsContent(
isTablet = false, isTablet = false,
heroEnabled = homescreenHeroEnabled, heroEnabled = homescreenHeroEnabled,
hideUnreleasedContent = homescreenHideUnreleasedContent,
items = homescreenItems, items = homescreenItems,
) )
SettingsPage.MetaScreen -> metaScreenSettingsContent( SettingsPage.MetaScreen -> metaScreenSettingsContent(
@ -461,6 +466,7 @@ private fun TabletSettingsScreen(
traktCommentsEnabled: Boolean, traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState, traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean, homescreenHeroEnabled: Boolean,
homescreenHideUnreleasedContent: Boolean,
homescreenItems: List<HomeCatalogSettingsItem>, homescreenItems: List<HomeCatalogSettingsItem>,
metaScreenSettingsUiState: MetaScreenSettingsUiState, metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
@ -640,6 +646,7 @@ private fun TabletSettingsScreen(
SettingsPage.Homescreen -> homescreenSettingsContent( SettingsPage.Homescreen -> homescreenSettingsContent(
isTablet = true, isTablet = true,
heroEnabled = homescreenHeroEnabled, heroEnabled = homescreenHeroEnabled,
hideUnreleasedContent = homescreenHideUnreleasedContent,
items = homescreenItems, items = homescreenItems,
) )
SettingsPage.MetaScreen -> metaScreenSettingsContent( SettingsPage.MetaScreen -> metaScreenSettingsContent(

View file

@ -1052,6 +1052,7 @@ object TmdbMetadataService {
posterShape = PosterShape.Poster, posterShape = PosterShape.Poster,
description = recommendation.overview?.trim()?.takeIf(String::isNotBlank), description = recommendation.overview?.trim()?.takeIf(String::isNotBlank),
releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4), releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4),
rawReleaseDate = recommendation.releaseDate ?: recommendation.firstAirDate,
imdbRating = recommendation.voteAverage?.formatRating(), imdbRating = recommendation.voteAverage?.formatRating(),
) )
} }
@ -1087,6 +1088,7 @@ object TmdbMetadataService {
posterShape = PosterShape.Landscape, posterShape = PosterShape.Landscape,
description = part.overview?.trim()?.takeIf(String::isNotBlank), description = part.overview?.trim()?.takeIf(String::isNotBlank),
releaseInfo = part.releaseDate?.take(4), releaseInfo = part.releaseDate?.take(4),
rawReleaseDate = part.releaseDate,
imdbRating = part.voteAverage?.formatRating(), imdbRating = part.voteAverage?.formatRating(),
) )
} }

View file

@ -49,4 +49,26 @@ class HomeCatalogParserTest {
result.items.map { it.stableKey() }, result.items.map { it.stableKey() },
) )
} }
@Test
fun `parse catalog response keeps raw released date for unreleased filtering`() {
val result = HomeCatalogParser.parseCatalogResponse(
payload = """
{
"metas": [
{
"id": "tt1",
"type": "movie",
"name": "Future Movie",
"releaseInfo": "2027",
"released": "2027-05-12T00:00:00.000Z"
}
]
}
""".trimIndent(),
)
assertEquals("2027", result.items.single().releaseInfo)
assertEquals("2027-05-12T00:00:00.000Z", result.items.single().rawReleaseDate)
}
} }

View file

@ -0,0 +1,72 @@
package com.nuvio.app.features.home
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ReleaseInfoUtilsTest {
@Test
fun `raw released date after today is unreleased`() {
val item = preview(rawReleaseDate = "2026-06-15T00:00:00.000Z", releaseInfo = "2026")
assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06"))
}
@Test
fun `release info full date after today is unreleased`() {
val item = preview(rawReleaseDate = null, releaseInfo = "2026-06-15")
assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06"))
}
@Test
fun `future release info year is unreleased`() {
val item = preview(rawReleaseDate = null, releaseInfo = "Coming in 2027")
assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06"))
}
@Test
fun `released and unknown dates are kept`() {
assertFalse(preview(rawReleaseDate = "2026-05-06", releaseInfo = "2026").isUnreleased("2026-05-06"))
assertFalse(preview(rawReleaseDate = "2026-05-05", releaseInfo = "2026").isUnreleased("2026-05-06"))
assertFalse(preview(rawReleaseDate = null, releaseInfo = null).isUnreleased("2026-05-06"))
}
@Test
fun `catalog section filters unreleased items`() {
val section = HomeCatalogSection(
key = "addon:movie:popular",
title = "Popular",
subtitle = "Addon",
addonName = "Addon",
type = "movie",
manifestUrl = "https://example.com/manifest.json",
catalogId = "popular",
items = listOf(
preview(id = "released", rawReleaseDate = "2026-05-01", releaseInfo = "2026"),
preview(id = "future", rawReleaseDate = "2026-07-01", releaseInfo = "2026"),
),
availableItemCount = 2,
)
val result = section.filterReleasedItems(todayIsoDate = "2026-05-06")
assertEquals(listOf("released"), result.items.map { it.id })
assertEquals(2, result.availableItemCount)
}
private fun preview(
id: String = "tt1",
rawReleaseDate: String?,
releaseInfo: String?,
): MetaPreview = MetaPreview(
id = id,
type = "movie",
name = id,
rawReleaseDate = rawReleaseDate,
releaseInfo = releaseInfo,
)
}