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)