From f4ff269d452666ea70b03ee1b90a3da5c6d027c5 Mon Sep 17 00:00:00 2001 From: YLaskco <2punksdnb@gmail.com> Date: Thu, 16 Jul 2026 16:41:41 -0400 Subject: [PATCH] Fix episode release date handling --- .../EpisodeReleaseDatePlatform.android.kt | 23 +++ .../tmdb/TmdbSettingsStorage.android.kt | 10 + .../composeResources/values/strings.xml | 2 + .../app/core/format/ReleaseDateDisplay.kt | 7 +- .../app/core/time/EpisodeReleaseDateParser.kt | 171 ++++++++++++++++++ .../core/time/EpisodeReleaseDatePlatform.kt | 7 + .../app/features/home/ReleaseInfoUtils.kt | 51 +++--- .../EpisodeReleaseNotificationsModels.kt | 7 +- .../app/features/settings/TmdbSettingsPage.kt | 11 ++ .../app/features/tmdb/TmdbMetadataService.kt | 15 +- .../nuvio/app/features/tmdb/TmdbSettings.kt | 1 + .../features/tmdb/TmdbSettingsRepository.kt | 25 +++ .../app/features/tmdb/TmdbSettingsStorage.kt | 2 + .../app/features/trakt/TraktIsoDateParser.kt | 70 +------ .../watching/domain/WatchingPolicies.kt | 68 +++---- .../features/watchprogress/AirDateUtils.kt | 17 +- .../core/time/EpisodeReleaseDateParserTest.kt | 65 +++++++ .../app/features/home/ReleaseInfoUtilsTest.kt | 12 +- .../features/tmdb/TmdbMetadataServiceTest.kt | 96 ++++++++++ .../watching/domain/WatchingPoliciesTest.kt | 40 ++++ .../watchprogress/WatchProgressRulesTest.kt | 3 +- .../time/EpisodeReleaseDatePlatform.ios.kt | 29 +++ .../features/tmdb/TmdbSettingsStorage.ios.kt | 10 + 23 files changed, 573 insertions(+), 169 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt new file mode 100644 index 00000000..bfcc0cf2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.core.time + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +internal actual object EpisodeReleaseDatePlatform { + actual fun nowEpochMs(): Long = System.currentTimeMillis() + + actual fun localIsoDateAtEpochMs(epochMs: Long): String? = runCatching { + Instant.ofEpochMilli(epochMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() + }.getOrNull() + + actual fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long? = runCatching { + LocalDateTime.parse(normalizedIsoDateTime) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt index 92e4b659..2de4822e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt @@ -20,6 +20,7 @@ actual object TmdbSettingsStorage { private const val useArtworkKey = "tmdb_use_artwork" private const val useBasicInfoKey = "tmdb_use_basic_info" private const val useDetailsKey = "tmdb_use_details" + private const val useReleaseDatesKey = "tmdb_use_release_dates" private const val useCreditsKey = "tmdb_use_credits" private const val useProductionsKey = "tmdb_use_productions" private const val useNetworksKey = "tmdb_use_networks" @@ -35,6 +36,7 @@ actual object TmdbSettingsStorage { useArtworkKey, useBasicInfoKey, useDetailsKey, + useReleaseDatesKey, useCreditsKey, useProductionsKey, useNetworksKey, @@ -100,6 +102,12 @@ actual object TmdbSettingsStorage { saveBoolean(useDetailsKey, enabled) } + actual fun loadUseReleaseDates(): Boolean? = loadBoolean(useReleaseDatesKey) + + actual fun saveUseReleaseDates(enabled: Boolean) { + saveBoolean(useReleaseDatesKey, enabled) + } + actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey) actual fun saveUseCredits(enabled: Boolean) { @@ -167,6 +175,7 @@ actual object TmdbSettingsStorage { loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) } loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) } loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) } + loadUseReleaseDates()?.let { put(useReleaseDatesKey, encodeSyncBoolean(it)) } loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) } loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) } loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) } @@ -188,6 +197,7 @@ actual object TmdbSettingsStorage { payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork) payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo) payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails) + payload.decodeSyncBoolean(useReleaseDatesKey)?.let(::saveUseReleaseDates) payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits) payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions) payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c09b2c4b..e02d10ec 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1065,6 +1065,8 @@ Cast with photos, director, and writer from TMDB Details Runtime, status, country, and language from TMDB + Release dates + Use TMDB broadcaster air dates instead of precise add-on release times Episodes Episode titles, overviews, thumbnails, and runtime from TMDB More Like This diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt index 73de69e8..d8efcf09 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt @@ -1,6 +1,7 @@ package com.nuvio.app.core.format import com.nuvio.app.core.i18n.localizedMonthName +import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate /** * Formats ISO calendar dates (yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss…) for UI as "2025 February 1". @@ -9,7 +10,7 @@ import com.nuvio.app.core.i18n.localizedMonthName fun formatReleaseDateForDisplay(raw: String): String { val trimmed = raw.trim() if (trimmed.isEmpty()) return raw - val datePart = trimmed.substringBefore('T').trim() + val datePart = parseEpisodeReleaseLocalDate(trimmed) ?: return raw val parts = datePart.split('-') if (parts.size != 3) return raw val year = parts[0].toIntOrNull() ?: return raw @@ -21,7 +22,7 @@ fun formatReleaseDateForDisplay(raw: String): String { fun formatReleaseDateWithoutYear(raw: String): String { val trimmed = raw.trim() if (trimmed.isEmpty()) return raw - val datePart = trimmed.substringBefore('T').trim() + val datePart = parseEpisodeReleaseLocalDate(trimmed) ?: return raw val parts = datePart.split('-') if (parts.size != 3) return raw val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return raw @@ -38,7 +39,7 @@ fun extractReleaseYearForDisplay(raw: String): Int? { if (t.length == 4 && t.all { it.isDigit() }) { return t.toIntOrNull()?.takeIf { it in 1000..9999 } } - val datePart = t.substringBefore('T').trim() + val datePart = parseEpisodeReleaseLocalDate(t) ?: return null val yearStr = datePart.split('-').firstOrNull() ?: return null return yearStr.toIntOrNull()?.takeIf { it in 1000..9999 } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt new file mode 100644 index 00000000..ad790367 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt @@ -0,0 +1,171 @@ +package com.nuvio.app.core.time + +private val IsoDateRegex = Regex("""^\d{4}-\d{2}-\d{2}$""") +private val EmbeddedIsoDateRegex = Regex("""(? String? = EpisodeReleaseDatePlatform::localIsoDateAtEpochMs, +): String? { + val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null + parseIsoCalendarDate(value)?.let { return it } + + parseZonedIsoDateTimeToEpochMs(value)?.let { epochMs -> + return localDateAtEpochMs(epochMs) + } + + parseLocalIsoDateTimeToEpochMs(value)?.let { + return parseIsoCalendarDate(value.take(10)) + } + + return EmbeddedIsoDateRegex.find(value)?.value?.let(::parseIsoCalendarDate) +} + +/** + * Zoned timestamps use their exact instant. Date only values use midnight UTC, while timestamps + * without a zone are interpreted in the viewer's timezone. + */ +internal fun parseEpisodeReleaseEpochMs(raw: String?): Long? { + val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return parseZonedIsoDateTimeToEpochMs(value) + ?: parseLocalIsoDateTimeToEpochMs(value) + ?: parseIsoCalendarDate(value)?.let(::isoDateAtUtcMidnightEpochMs) + ?: EmbeddedIsoDateRegex.find(value)?.value + ?.let(::parseIsoCalendarDate) + ?.let(::isoDateAtUtcMidnightEpochMs) +} + +internal fun isEpisodeReleaseAired( + raw: String?, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): Boolean? = parseEpisodeReleaseEpochMs(raw)?.let { releaseEpochMs -> + releaseEpochMs <= nowEpochMs +} + +internal fun daysUntilEpisodeRelease( + todayIsoDate: String, + releasedDate: String?, + localDateAtEpochMs: (Long) -> String? = EpisodeReleaseDatePlatform::localIsoDateAtEpochMs, +): Int? { + val startDate = parseIsoCalendarDate(todayIsoDate.trim()) ?: return null + val targetDate = parseEpisodeReleaseLocalDate(releasedDate, localDateAtEpochMs) ?: return null + return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt() +} + +internal fun parseIsoCalendarDate(value: String?): String? { + val date = value?.trim()?.takeIf(IsoDateRegex::matches) ?: 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 +} + +internal fun parseZonedIsoDateTimeToEpochMs(value: String): Long? { + val match = ZonedIsoDateTimeRegex.matchEntire(value.trim()) ?: return null + val components = match.dateTimeComponentsOrNull() ?: return null + val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null + return components.toUtcEpochMs() - offsetMs +} + +private fun parseLocalIsoDateTimeToEpochMs(value: String): Long? { + val match = LocalIsoDateTimeRegex.matchEntire(value) ?: return null + val components = match.dateTimeComponentsOrNull() ?: return null + return EpisodeReleaseDatePlatform.localDateTimeToEpochMs(components.normalizedLocalDateTime()) +} + +private data class DateTimeComponents( + val year: Int, + val month: Int, + val day: Int, + val hour: Int, + val minute: Int, + val second: Int, + val millisecond: Int, +) { + fun toUtcEpochMs(): Long = + isoEpochDay(year, month, day) * MillisPerDay + + hour * MillisPerHour + + minute * MillisPerMinute + + second * MillisPerSecond + + millisecond + + fun normalizedLocalDateTime(): String = + year.toString().padStart(4, '0') + "-" + + month.toString().padStart(2, '0') + "-" + + day.toString().padStart(2, '0') + "T" + + hour.toString().padStart(2, '0') + ":" + + minute.toString().padStart(2, '0') + ":" + + second.toString().padStart(2, '0') + "." + + millisecond.toString().padStart(3, '0') +} + +private fun MatchResult.dateTimeComponentsOrNull(): DateTimeComponents? { + val year = groupValues[1].toIntOrNull() ?: return null + val month = groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null + val day = groupValues[3].toIntOrNull() ?: return null + val hour = groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null + val minute = groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null + val second = groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null + if (day !in 1..daysInMonth(year, month)) return null + val millisecond = groupValues[7] + .takeIf { it.isNotEmpty() } + ?.padEnd(3, '0') + ?.take(3) + ?.toIntOrNull() + ?: 0 + return DateTimeComponents(year, month, day, hour, minute, second, millisecond) +} + +private fun parseOffsetMs(value: String): Long? { + if (value == "Z") return 0L + val sign = when (value.firstOrNull()) { + '+' -> 1L + '-' -> -1L + else -> return null + } + val digits = value.drop(1).replace(":", "") + if (digits.length != 4) return null + val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null + val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null + return sign * (hours * MillisPerHour + minutes * MillisPerMinute) +} + +private fun isoDateAtUtcMidnightEpochMs(date: String): Long = isoEpochDay(date) * MillisPerDay + +internal fun isoEpochDay(date: String): Long = isoEpochDay( + year = date.substring(0, 4).toInt(), + month = date.substring(5, 7).toInt(), + day = date.substring(8, 10).toInt(), +) + +private fun isoEpochDay(year: Int, month: Int, day: Int): Long { + val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L + val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L + val yearOfEra = adjustedYear - era * 400L + val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L + val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L + val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear + return era * 146_097L + dayOfEra - 719_468L +} + +private fun daysInMonth(year: Int, month: Int): Int = when (month) { + 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 +} + +private const val MillisPerSecond = 1_000L +private const val MillisPerMinute = 60L * MillisPerSecond +private const val MillisPerHour = 60L * MillisPerMinute +private const val MillisPerDay = 24L * MillisPerHour diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt new file mode 100644 index 00000000..86980086 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.core.time + +internal expect object EpisodeReleaseDatePlatform { + fun nowEpochMs(): Long + fun localIsoDateAtEpochMs(epochMs: Long): String? + fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long? +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt index f7b3bf41..a6269454 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt @@ -1,21 +1,26 @@ 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}""") +import com.nuvio.app.core.time.EpisodeReleaseDatePlatform +import com.nuvio.app.core.time.isEpisodeReleaseAired -internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean { +private val yearRegex = Regex("""\b(19|20)\d{2}\b""") + +internal fun MetaPreview.isUnreleased( + todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): Boolean { rawReleaseDate ?.trim() ?.takeIf { it.isNotEmpty() } ?.let { rawReleased -> - isoCalendarDateOrNull(rawReleased.substringBefore('T'))?.let { releaseDate -> - return releaseDate > todayIsoDate + isEpisodeReleaseAired(rawReleased, nowEpochMs)?.let { hasAired -> + return !hasAired } } val info = releaseInfo ?: return false - isoCalendarDateOrNull(info.trim())?.let { releaseDate -> - return releaseDate > todayIsoDate + isEpisodeReleaseAired(info.trim(), nowEpochMs)?.let { hasAired -> + return !hasAired } val releaseYear = yearRegex.find(info)?.value?.toIntOrNull() ?: return false @@ -23,29 +28,15 @@ internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean { return releaseYear > currentYear } -internal fun HomeCatalogSection.filterReleasedItems(todayIsoDate: String): HomeCatalogSection { - val filteredItems = items.filterReleasedItems(todayIsoDate) +internal fun HomeCatalogSection.filterReleasedItems( + todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): HomeCatalogSection { + val filteredItems = items.filterReleasedItems(todayIsoDate, nowEpochMs) return if (filteredItems.size == items.size) this else copy(items = filteredItems) } -internal fun List.filterReleasedItems(todayIsoDate: String): List = - 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) +internal fun List.filterReleasedItems( + todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): List = filterNot { item -> item.isUnreleased(todayIsoDate, nowEpochMs) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt index 2e71f651..73d50481 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.notifications +import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.Res @@ -62,11 +63,7 @@ internal fun normalizeSeriesType(type: String): String = when (type.trim().lower internal fun isSeriesLibraryType(type: String): Boolean = normalizeSeriesType(type) == "series" internal fun releaseDateIso(rawValue: String?): String? { - val value = rawValue - ?.substringBefore('T') - ?.trim() - .orEmpty() - return value.takeIf { it.length == 10 } + return parseEpisodeReleaseLocalDate(rawValue) } internal fun buildEpisodeReleaseNotificationId( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt index ed2fbe31..57d7a3cd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt @@ -42,6 +42,8 @@ import nuvio.composeapp.generated.resources.settings_tmdb_module_details import nuvio.composeapp.generated.resources.settings_tmdb_module_details_description import nuvio.composeapp.generated.resources.settings_tmdb_module_episodes import nuvio.composeapp.generated.resources.settings_tmdb_module_episodes_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_release_dates +import nuvio.composeapp.generated.resources.settings_tmdb_module_release_dates_description import nuvio.composeapp.generated.resources.settings_tmdb_module_more_like_this import nuvio.composeapp.generated.resources.settings_tmdb_module_more_like_this_description import nuvio.composeapp.generated.resources.settings_tmdb_module_networks @@ -166,6 +168,15 @@ internal fun LazyListScope.tmdbSettingsContent( onCheckedChange = TmdbSettingsRepository::setUseDetails, ) SettingsGroupDivider(isTablet = isTablet) + TmdbToggleRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_tmdb_module_release_dates), + description = stringResource(Res.string.settings_tmdb_module_release_dates_description), + checked = settings.useReleaseDates, + enabled = enrichmentControlsEnabled, + onCheckedChange = TmdbSettingsRepository::setUseReleaseDates, + ) + SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, title = stringResource(Res.string.settings_tmdb_module_credits), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index c2a6eaec..b8763562 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -528,7 +528,9 @@ object TmdbMetadataService { ?: TmdbService.ensureTmdbId(fallbackItemId, tmdbType) ?: return meta - val needsEpisodes = (settings.useEpisodes || settings.useSeasonPosters) && tmdbType == "tv" + val needsEpisodes = ( + settings.useEpisodes || settings.useReleaseDates || settings.useSeasonPosters + ) && tmdbType == "tv" val (enrichment, episodeMap) = coroutineScope { val enrichmentDeferred = async { fetchEnrichment( @@ -655,8 +657,6 @@ object TmdbMetadataService { if (enrichment != null && settings.useDetails) { updated = updated.copy( - releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo, - lastAirDate = enrichment.lastAirDate ?: updated.lastAirDate, status = enrichment.status ?: updated.status, ageRating = enrichment.ageRating ?: updated.ageRating, runtime = enrichment.runtimeMinutes?.formatRuntime() ?: updated.runtime, @@ -665,6 +665,13 @@ object TmdbMetadataService { ) } + if (enrichment != null && settings.useReleaseDates) { + updated = updated.copy( + releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo, + lastAirDate = enrichment.lastAirDate ?: updated.lastAirDate, + ) + } + if (enrichment != null && settings.useCredits) { updated = updated.copy( director = enrichment.director.ifEmpty { updated.director }, @@ -702,7 +709,7 @@ object TmdbMetadataService { } else { video.overview }, - released = if (settings.useEpisodes) { + released = if (settings.useReleaseDates) { enrichmentForEpisode.airDate ?: video.released } else { video.released diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt index 1cf82e88..e7c4a77d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt @@ -8,6 +8,7 @@ data class TmdbSettings( val useArtwork: Boolean = true, val useBasicInfo: Boolean = true, val useDetails: Boolean = true, + val useReleaseDates: Boolean = false, val useCredits: Boolean = true, val useProductions: Boolean = true, val useNetworks: Boolean = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt index fa5586c0..9169a31f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt @@ -1,5 +1,8 @@ package com.nuvio.app.features.tmdb +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,6 +20,7 @@ object TmdbSettingsRepository { private var useArtwork = true private var useBasicInfo = true private var useDetails = true + private var useReleaseDates = false private var useCredits = true private var useProductions = true private var useNetworks = true @@ -98,6 +102,15 @@ object TmdbSettingsRepository { persist = TmdbSettingsStorage::saveUseDetails, ) + fun setUseReleaseDates(value: Boolean) { + ensureLoaded() + if (useReleaseDates == value) return + useReleaseDates = value + publish() + TmdbSettingsStorage.saveUseReleaseDates(value) + invalidateReleaseDateMetadata() + } + fun setUseCredits(value: Boolean) = setBoolean( current = useCredits, next = value, @@ -161,6 +174,8 @@ object TmdbSettingsRepository { } private fun loadFromDisk() { + val wasLoaded = hasLoaded + val previousUseReleaseDates = useReleaseDates hasLoaded = true apiKey = TmdbSettingsStorage.loadApiKey()?.trim().orEmpty() enabled = (TmdbSettingsStorage.loadEnabled() ?: false) && apiKey.isNotBlank() @@ -170,6 +185,7 @@ object TmdbSettingsRepository { useArtwork = TmdbSettingsStorage.loadUseArtwork() ?: true useBasicInfo = TmdbSettingsStorage.loadUseBasicInfo() ?: true useDetails = TmdbSettingsStorage.loadUseDetails() ?: true + useReleaseDates = TmdbSettingsStorage.loadUseReleaseDates() ?: false useCredits = TmdbSettingsStorage.loadUseCredits() ?: true useProductions = TmdbSettingsStorage.loadUseProductions() ?: true useNetworks = TmdbSettingsStorage.loadUseNetworks() ?: true @@ -178,6 +194,9 @@ object TmdbSettingsRepository { useMoreLikeThis = TmdbSettingsStorage.loadUseMoreLikeThis() ?: true useCollections = TmdbSettingsStorage.loadUseCollections() ?: true publish() + if (wasLoaded && previousUseReleaseDates != useReleaseDates) { + invalidateReleaseDateMetadata() + } } private fun publish() { @@ -189,6 +208,7 @@ object TmdbSettingsRepository { useArtwork = useArtwork, useBasicInfo = useBasicInfo, useDetails = useDetails, + useReleaseDates = useReleaseDates, useCredits = useCredits, useProductions = useProductions, useNetworks = useNetworks, @@ -198,6 +218,11 @@ object TmdbSettingsRepository { useCollections = useCollections, ) } + + private fun invalidateReleaseDateMetadata() { + MetaDetailsRepository.clear() + ContinueWatchingEnrichmentCache.clearAll(ProfileRepository.activeProfileId) + } } internal fun normalizeLanguage(value: String?): String { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt index f4550bdb..c37e0516 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt @@ -17,6 +17,8 @@ internal expect object TmdbSettingsStorage { fun saveUseBasicInfo(enabled: Boolean) fun loadUseDetails(): Boolean? fun saveUseDetails(enabled: Boolean) + fun loadUseReleaseDates(): Boolean? + fun saveUseReleaseDates(enabled: Boolean) fun loadUseCredits(): Boolean? fun saveUseCredits(enabled: Boolean) fun loadUseProductions(): Boolean? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt index 79b5bd07..d34381ec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt @@ -1,70 +1,6 @@ package com.nuvio.app.features.trakt -private val TraktIsoDateTimeRegex = Regex( - """^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$""", -) +import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs -internal fun parseTraktIsoDateTimeToEpochMs(value: String): Long? { - val match = TraktIsoDateTimeRegex.matchEntire(value.trim()) ?: return null - val year = match.groupValues[1].toIntOrNull() ?: return null - val month = match.groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null - val day = match.groupValues[3].toIntOrNull() ?: return null - val hour = match.groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null - val minute = match.groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null - val second = match.groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null - if (day !in 1..daysInMonth(year, month)) return null - - val millisecond = match.groupValues[7] - .takeIf { it.isNotEmpty() } - ?.padEnd(3, '0') - ?.take(3) - ?.toIntOrNull() - ?: 0 - val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null - - return isoEpochDay(year, month, day) * MillisPerDay + - hour * MillisPerHour + - minute * MillisPerMinute + - second * MillisPerSecond + - millisecond - - offsetMs -} - -private fun parseOffsetMs(value: String): Long? { - if (value == "Z") return 0L - val sign = when (value.firstOrNull()) { - '+' -> 1L - '-' -> -1L - else -> return null - } - val digits = value.drop(1).replace(":", "") - if (digits.length != 4) return null - val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null - val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null - return sign * ((hours * MillisPerHour) + (minutes * MillisPerMinute)) -} - -private fun isoEpochDay(year: Int, month: Int, day: Int): Long { - val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L - val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L - val yearOfEra = adjustedYear - era * 400L - val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L - val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L - val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear - return era * 146_097L + dayOfEra - 719_468L -} - -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) - -private const val MillisPerSecond = 1_000L -private const val MillisPerMinute = 60L * MillisPerSecond -private const val MillisPerHour = 60L * MillisPerMinute -private const val MillisPerDay = 24L * MillisPerHour +internal fun parseTraktIsoDateTimeToEpochMs(value: String): Long? = + parseZonedIsoDateTimeToEpochMs(value) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt index 49e38623..92047bb2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt @@ -1,5 +1,11 @@ package com.nuvio.app.features.watching.domain +import com.nuvio.app.core.time.EpisodeReleaseDatePlatform +import com.nuvio.app.core.time.daysUntilEpisodeRelease +import com.nuvio.app.core.time.isEpisodeReleaseAired +import com.nuvio.app.core.time.isoEpochDay as coreIsoEpochDay +import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate + private const val CompletionThresholdFraction = 0.90 private const val ProgressStoreThresholdMs = 1_000L private const val UpcomingNextSeasonWindowDays = 7 @@ -31,13 +37,10 @@ fun isReleasedBy( todayIsoDate: String, releasedDate: String?, available: Boolean = true, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): Boolean { if (!available) return false - val isoDate = releasedDate - ?.substringBefore('T') - ?.takeIf { it.length == 10 } - ?: return true - return isoDate <= todayIsoDate + return isEpisodeReleaseAired(releasedDate, nowEpochMs) ?: true } internal fun shouldSurfaceNextEpisode( @@ -47,6 +50,7 @@ internal fun shouldSurfaceNextEpisode( releasedDate: String?, showUnairedNextUp: Boolean, available: Boolean = true, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): Boolean { val isSeasonRollover = normalizeSeasonNumber(candidateSeasonNumber) != normalizeSeasonNumber(watchedSeasonNumber) if (!available) { @@ -59,10 +63,14 @@ internal fun shouldSurfaceNextEpisode( } if (!isSeasonRollover) { if (showUnairedNextUp) return true - return isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate) + return isReleasedBy( + todayIsoDate = todayIsoDate, + releasedDate = releasedDate, + nowEpochMs = nowEpochMs, + ) } - if (isExplicitlyReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate)) { + if (isExplicitlyReleasedBy(releasedDate = releasedDate, nowEpochMs = nowEpochMs)) { return true } if (!showUnairedNextUp) { @@ -77,70 +85,44 @@ internal fun shouldSurfaceNextEpisode( } private fun isExplicitlyReleasedBy( - todayIsoDate: String, releasedDate: String?, + nowEpochMs: Long, ): Boolean { - val isoDate = isoCalendarDateOrNull(releasedDate) ?: return false - return isoDate <= todayIsoDate + return isEpisodeReleaseAired(releasedDate, nowEpochMs) ?: false } internal fun daysUntilExplicitRelease( todayIsoDate: String, releasedDate: String?, ): Int? { - val startDate = isoCalendarDateOrNull(todayIsoDate) ?: return null - val targetDate = isoCalendarDateOrNull(releasedDate) ?: return null - return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt() + return daysUntilEpisodeRelease(todayIsoDate, releasedDate) } -internal fun isoCalendarDateOrNull(value: String?): String? { - val datePart = value - ?.trim() - ?.substringBefore('T') - ?.takeIf { it.length == 10 } - ?: return null - val parts = datePart.split('-') - if (parts.size != 3) return null - val year = parts[0].toIntOrNull() ?: return null - val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return null - val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return null - val normalizedYear = year.toString().padStart(4, '0') - val normalizedMonth = month.toString().padStart(2, '0') - val normalizedDay = day.toString().padStart(2, '0') - return "$normalizedYear-$normalizedMonth-$normalizedDay" -} +internal fun isoCalendarDateOrNull(value: String?): String? = parseEpisodeReleaseLocalDate(value) -internal fun isoEpochDay(date: String): Long { - val year = date.substring(0, 4).toLong() - val month = date.substring(5, 7).toLong() - val day = date.substring(8, 10).toLong() - - val adjustedYear = year - if (month <= 2L) 1L else 0L - val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L - val yearOfEra = adjustedYear - era * 400L - val adjustedMonth = month + if (month > 2L) -3L else 9L - val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L - val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear - return era * 146_097L + dayOfEra - 719_468L -} +internal fun isoEpochDay(date: String): Long = coreIsoEpochDay(date) fun releasedEpisodes( episodes: List, todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): List = episodes.filter { episode -> isReleasedBy( todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, available = episode.available, + nowEpochMs = nowEpochMs, ) } fun releasedMainSeasonEpisodes( episodes: List, todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): List = releasedEpisodes( episodes = episodes, todayIsoDate = todayIsoDate, + nowEpochMs = nowEpochMs, ).filter { episode -> normalizeSeasonNumber(episode.seasonNumber) > 0 } @@ -148,11 +130,13 @@ fun releasedMainSeasonEpisodes( fun hasWatchedAllMainSeasonEpisodes( episodes: List, todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), isEpisodeWatched: (WatchingReleasedEpisode) -> Boolean, ): Boolean { val mainSeasonEpisodes = releasedMainSeasonEpisodes( episodes = episodes, todayIsoDate = todayIsoDate, + nowEpochMs = nowEpochMs, ) return mainSeasonEpisodes.isNotEmpty() && mainSeasonEpisodes.all(isEpisodeWatched) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index 51980b2c..d2809caf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt @@ -2,9 +2,8 @@ package com.nuvio.app.features.watchprogress import androidx.compose.runtime.Composable import com.nuvio.app.core.format.formatReleaseDateWithoutYear -import com.nuvio.app.features.watching.domain.daysUntilExplicitRelease -import com.nuvio.app.features.watching.domain.isoCalendarDateOrNull -import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs +import com.nuvio.app.core.time.daysUntilEpisodeRelease +import com.nuvio.app.core.time.parseEpisodeReleaseEpochMs import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource @@ -20,12 +19,12 @@ fun computeAirDateBadgeText( return null } - val releaseEpoch = parseTraktIsoDateTimeToEpochMs(releasedIso) + val releaseEpoch = parseEpisodeReleaseEpochMs(releasedIso) if (releaseEpoch != null && WatchProgressClock.nowEpochMs() >= releaseEpoch) { return null } - val daysUntil = daysUntilExplicitRelease( + val daysUntil = daysUntilEpisodeRelease( todayIsoDate = todayIsoDate, releasedDate = releasedIso, ) ?: return null @@ -53,13 +52,7 @@ fun computeAirDateBadgeText( } fun parseReleaseDateToEpochMs(raw: String?): Long? { - if (raw.isNullOrBlank()) return null - val trimmed = raw.trim() - val epochMs = parseTraktIsoDateTimeToEpochMs(trimmed) - if (epochMs != null) return epochMs - - val datePart = isoCalendarDateOrNull(trimmed) ?: return null - return CurrentDateProvider.localStartOfDayEpochMs(datePart) + return parseEpisodeReleaseEpochMs(raw) } class ReleaseAlertState( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt new file mode 100644 index 00000000..1117475a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt @@ -0,0 +1,65 @@ +package com.nuvio.app.core.time + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EpisodeReleaseDateParserTest { + @Test + fun zonedTimestampUsesViewerLocalCalendarDate() { + assertEquals( + "2026-07-15", + parseEpisodeReleaseLocalDate("2026-07-16T00:00:00.000Z") { "2026-07-15" }, + ) + } + + @Test + fun plainDateIsPreservedWithoutViewerTimezoneConversion() { + assertEquals( + "2026-07-16", + parseEpisodeReleaseLocalDate("2026-07-16") { "should-not-be-used" }, + ) + } + + @Test + fun zonedReleaseStaysUnavailableUntilExactInstant() { + val exact = requireNotNull(parseEpisodeReleaseEpochMs("2026-07-15T15:00:00Z")) + + assertFalse(isEpisodeReleaseAired("2026-07-15T15:00:00Z", exact - 1L)!!) + assertTrue(isEpisodeReleaseAired("2026-07-15T15:00:00Z", exact)!!) + } + + @Test + fun dateOnlyReleaseStartsAtUtcMidnight() { + val utcMidnight = requireNotNull(parseEpisodeReleaseEpochMs("2026-07-15T00:00:00Z")) + + assertEquals(utcMidnight, parseEpisodeReleaseEpochMs("2026-07-15")) + assertFalse(isEpisodeReleaseAired("2026-07-15", utcMidnight - 1L)!!) + assertTrue(isEpisodeReleaseAired("2026-07-15", utcMidnight)!!) + } + + @Test + fun offsetTimestampResolvesToSameInstant() { + assertEquals( + parseEpisodeReleaseEpochMs("2026-07-15T23:00:00Z"), + parseEpisodeReleaseEpochMs("2026-07-16T01:00:00+02:00"), + ) + } + + @Test + fun invalidAndMissingValuesRemainUnknown() { + assertNull(parseEpisodeReleaseLocalDate("not-a-date")) + assertNull(parseEpisodeReleaseEpochMs(null)) + assertNull(isEpisodeReleaseAired("not-a-date")) + } + + @Test + fun countdownUsesViewerLocalDateForZonedTimestamp() { + assertEquals( + 0, + daysUntilEpisodeRelease("2026-07-15", "2026-07-16T00:00:00Z") { "2026-07-15" }, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt index 349a57e9..c4583940 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.home +import com.nuvio.app.core.time.parseEpisodeReleaseEpochMs import com.nuvio.app.features.catalog.CatalogTarget import kotlin.test.Test import kotlin.test.assertEquals @@ -7,19 +8,20 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class ReleaseInfoUtilsTest { + private val nowEpochMs = requireNotNull(parseEpisodeReleaseEpochMs("2026-05-06T12:00:00Z")) @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")) + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06", nowEpochMs = nowEpochMs)) } @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")) + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06", nowEpochMs = nowEpochMs)) } @Test @@ -31,8 +33,8 @@ class ReleaseInfoUtilsTest { @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 = "2026-05-06", releaseInfo = "2026").isUnreleased("2026-05-06", nowEpochMs)) + assertFalse(preview(rawReleaseDate = "2026-05-05", releaseInfo = "2026").isUnreleased("2026-05-06", nowEpochMs)) assertFalse(preview(rawReleaseDate = null, releaseInfo = null).isUnreleased("2026-05-06")) } @@ -55,7 +57,7 @@ class ReleaseInfoUtilsTest { availableItemCount = 2, ) - val result = section.filterReleasedItems(todayIsoDate = "2026-05-06") + val result = section.filterReleasedItems(todayIsoDate = "2026-05-06", nowEpochMs = nowEpochMs) assertEquals(listOf("released"), result.items.map { it.id }) assertEquals(2, result.availableItemCount) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt index 22dd5a59..bd67afdd 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt @@ -60,6 +60,7 @@ class TmdbMetadataServiceTest { MetaVideo( id = "ep1", title = "Episode 1", + released = "2023-12-31T19:00:00Z", season = 1, episode = 1, ), @@ -113,6 +114,101 @@ class TmdbMetadataServiceTest { assertEquals(listOf("HBO"), result.networks.map { it.name }) assertEquals("Pilot", result.videos.first().title) assertEquals(58, result.videos.first().runtime) + assertEquals("2023-12-31T19:00:00Z", result.videos.first().released) + } + + @Test + fun `applyEnrichment replaces episode release only when enabled`() { + val addonRelease = "2023-12-31T19:00:00Z" + val base = MetaDetails( + id = "tt1234567", + type = "series", + name = "Original", + videos = listOf( + MetaVideo( + id = "ep1", + title = "Episode 1", + released = addonRelease, + season = 1, + episode = 1, + ), + ), + ) + val episodes = mapOf( + (1 to 1) to TmdbEpisodeEnrichment( + title = null, + overview = null, + thumbnail = null, + airDate = "2024-01-01", + runtimeMinutes = null, + ), + ) + + val disabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = null, + episodeMap = episodes, + settings = TmdbSettings(enabled = true), + ) + val enabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = null, + episodeMap = episodes, + settings = TmdbSettings(enabled = true, useReleaseDates = true), + ) + + assertEquals(addonRelease, disabled.videos.first().released) + assertEquals("2024-01-01", enabled.videos.first().released) + } + + @Test + fun `applyEnrichment replaces top level release dates only when enabled`() { + val base = MetaDetails( + id = "tt1234567", + type = "series", + name = "Original", + releaseInfo = "2023-12-31T19:00:00Z", + lastAirDate = "2023-12-31T20:00:00Z", + ) + val enrichment = TmdbEnrichment( + localizedTitle = null, + description = null, + genres = emptyList(), + backdrop = null, + logo = null, + poster = null, + people = emptyList(), + director = emptyList(), + writer = emptyList(), + releaseInfo = "2024-01-01", + lastAirDate = "2024-12-31", + rating = null, + runtimeMinutes = null, + ageRating = null, + status = null, + countries = emptyList(), + language = null, + productionCompanies = emptyList(), + networks = emptyList(), + ) + + val disabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = enrichment, + episodeMap = emptyMap(), + settings = TmdbSettings(enabled = true), + ) + val enabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = enrichment, + episodeMap = emptyMap(), + settings = TmdbSettings(enabled = true, useReleaseDates = true), + ) + + assertEquals(base.releaseInfo, disabled.releaseInfo) + assertEquals(base.lastAirDate, disabled.lastAirDate) + assertEquals("2024-01-01", enabled.releaseInfo) + assertEquals("2024-12-31", enabled.lastAirDate) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt index f9942e7f..33d3f12e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt @@ -9,6 +9,46 @@ import kotlin.test.assertTrue class WatchingPoliciesTest { private val show = WatchingContentRef(type = "series", id = "show") + @Test + fun isReleasedByUsesExactInstantForZonedTimestamps() { + val exactEpochMs = 1_768_489_200_000L // 2026-01-15T15:00:00Z + + assertFalse( + isReleasedBy( + todayIsoDate = "2026-01-15", + releasedDate = "2026-01-15T15:00:00Z", + nowEpochMs = exactEpochMs - 1L, + ), + ) + assertTrue( + isReleasedBy( + todayIsoDate = "2026-01-15", + releasedDate = "2026-01-15T15:00:00Z", + nowEpochMs = exactEpochMs, + ), + ) + } + + @Test + fun isReleasedByTreatsDateOnlyValueAsUtcMidnight() { + val utcMidnightEpochMs = 1_768_435_200_000L // 2026-01-15T00:00:00Z + + assertFalse( + isReleasedBy( + todayIsoDate = "2026-01-14", + releasedDate = "2026-01-15", + nowEpochMs = utcMidnightEpochMs - 1L, + ), + ) + assertTrue( + isReleasedBy( + todayIsoDate = "2026-01-14", + releasedDate = "2026-01-15", + nowEpochMs = utcMidnightEpochMs, + ), + ) + } + @Test fun hasWatchedAllMainSeasonEpisodes_ignores_specials() { val episodes = listOf( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index fc1e76c9..72fcca6d 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.features.cloud.TorboxCloudLibraryPosterUrl import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -481,7 +482,7 @@ class WatchProgressRulesTest { assertEquals(1779634800000L, t1) val t2 = parseReleaseDateToEpochMs("2026-05-24") - assertEquals(CurrentDateProvider.localStartOfDayEpochMs("2026-05-24"), t2) + assertEquals(parseTraktIsoDateTimeToEpochMs("2026-05-24T00:00:00Z"), t2) assertNull(parseReleaseDateToEpochMs(null)) assertNull(parseReleaseDateToEpochMs(" ")) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt new file mode 100644 index 00000000..506a3804 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.core.time + +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.Foundation.timeIntervalSince1970 + +internal actual object EpisodeReleaseDatePlatform { + actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1_000.0).toLong() + + actual fun localIsoDateAtEpochMs(epochMs: Long): String? { + val formatter = NSDateFormatter().apply { + dateFormat = "yyyy-MM-dd" + } + return formatter.stringFromDate( + NSDate.dateWithTimeIntervalSince1970(epochMs.toDouble() / 1_000.0), + ) + } + + actual fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long? { + val formatter = NSDateFormatter().apply { + dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + } + return formatter.dateFromString(normalizedIsoDateTime) + ?.timeIntervalSince1970 + ?.times(1_000.0) + ?.toLong() + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt index bee39ff6..2754c950 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt @@ -18,6 +18,7 @@ actual object TmdbSettingsStorage { private const val useArtworkKey = "tmdb_use_artwork" private const val useBasicInfoKey = "tmdb_use_basic_info" private const val useDetailsKey = "tmdb_use_details" + private const val useReleaseDatesKey = "tmdb_use_release_dates" private const val useCreditsKey = "tmdb_use_credits" private const val useProductionsKey = "tmdb_use_productions" private const val useNetworksKey = "tmdb_use_networks" @@ -33,6 +34,7 @@ actual object TmdbSettingsStorage { useArtworkKey, useBasicInfoKey, useDetailsKey, + useReleaseDatesKey, useCreditsKey, useProductionsKey, useNetworksKey, @@ -86,6 +88,12 @@ actual object TmdbSettingsStorage { saveBoolean(useDetailsKey, enabled) } + actual fun loadUseReleaseDates(): Boolean? = loadBoolean(useReleaseDatesKey) + + actual fun saveUseReleaseDates(enabled: Boolean) { + saveBoolean(useReleaseDatesKey, enabled) + } + actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey) actual fun saveUseCredits(enabled: Boolean) { @@ -150,6 +158,7 @@ actual object TmdbSettingsStorage { loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) } loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) } loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) } + loadUseReleaseDates()?.let { put(useReleaseDatesKey, encodeSyncBoolean(it)) } loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) } loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) } loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) } @@ -171,6 +180,7 @@ actual object TmdbSettingsStorage { payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork) payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo) payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails) + payload.decodeSyncBoolean(useReleaseDatesKey)?.let(::saveUseReleaseDates) payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits) payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions) payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks)