mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 14:32:28 +00:00
Merge pull request #1558 from Laskco/codex/tmdb-release-date-toggle
Add TMDB release date controls and fix episode availability
This commit is contained in:
commit
0cc804198a
23 changed files with 573 additions and 169 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1065,6 +1065,8 @@
|
|||
<string name="settings_tmdb_module_credits_description">Cast with photos, director, and writer from TMDB</string>
|
||||
<string name="settings_tmdb_module_details">Details</string>
|
||||
<string name="settings_tmdb_module_details_description">Runtime, status, country, and language from TMDB</string>
|
||||
<string name="settings_tmdb_module_release_dates">Release dates</string>
|
||||
<string name="settings_tmdb_module_release_dates_description">Use TMDB broadcaster air dates instead of precise add-on release times</string>
|
||||
<string name="settings_tmdb_module_episodes">Episodes</string>
|
||||
<string name="settings_tmdb_module_episodes_description">Episode titles, overviews, thumbnails, and runtime from TMDB</string>
|
||||
<string name="settings_tmdb_module_more_like_this">More Like This</string>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
package com.nuvio.app.core.time
|
||||
|
||||
private val IsoDateRegex = Regex("""^\d{4}-\d{2}-\d{2}$""")
|
||||
private val EmbeddedIsoDateRegex = Regex("""(?<!\d)\d{4}-\d{2}-\d{2}(?!\d)""")
|
||||
private val ZonedIsoDateTimeRegex = Regex(
|
||||
"""^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$""",
|
||||
)
|
||||
private val LocalIsoDateTimeRegex = Regex(
|
||||
"""^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?$""",
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts timestamped releases into the viewer's local calendar date. Plain dates have no
|
||||
* timezone information and are preserved as supplied by the provider.
|
||||
*/
|
||||
internal fun parseEpisodeReleaseLocalDate(
|
||||
raw: String?,
|
||||
localDateAtEpochMs: (Long) -> 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
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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<MetaPreview>.filterReleasedItems(todayIsoDate: String): List<MetaPreview> =
|
||||
filterNot { item -> item.isUnreleased(todayIsoDate) }
|
||||
|
||||
private fun isoCalendarDateOrNull(value: String?): String? {
|
||||
val date = value?.trim()?.takeIf { isoDateRegex.matches(it) } ?: return null
|
||||
val year = date.substring(0, 4).toIntOrNull() ?: return null
|
||||
val month = date.substring(5, 7).toIntOrNull()?.takeIf { it in 1..12 } ?: return null
|
||||
val day = date.substring(8, 10).toIntOrNull() ?: return null
|
||||
if (day !in 1..daysInMonth(year, month)) return null
|
||||
return date
|
||||
}
|
||||
|
||||
private fun daysInMonth(year: Int, month: Int): Int =
|
||||
when (month) {
|
||||
2 -> if (isLeapYear(year)) 29 else 28
|
||||
4, 6, 9, 11 -> 30
|
||||
else -> 31
|
||||
}
|
||||
|
||||
private fun isLeapYear(year: Int): Boolean =
|
||||
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
|
||||
internal fun List<MetaPreview>.filterReleasedItems(
|
||||
todayIsoDate: String,
|
||||
nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(),
|
||||
): List<MetaPreview> = filterNot { item -> item.isUnreleased(todayIsoDate, nowEpochMs) }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<WatchingReleasedEpisode>,
|
||||
todayIsoDate: String,
|
||||
nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(),
|
||||
): List<WatchingReleasedEpisode> = episodes.filter { episode ->
|
||||
isReleasedBy(
|
||||
todayIsoDate = todayIsoDate,
|
||||
releasedDate = episode.releasedDate,
|
||||
available = episode.available,
|
||||
nowEpochMs = nowEpochMs,
|
||||
)
|
||||
}
|
||||
|
||||
fun releasedMainSeasonEpisodes(
|
||||
episodes: List<WatchingReleasedEpisode>,
|
||||
todayIsoDate: String,
|
||||
nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(),
|
||||
): List<WatchingReleasedEpisode> = releasedEpisodes(
|
||||
episodes = episodes,
|
||||
todayIsoDate = todayIsoDate,
|
||||
nowEpochMs = nowEpochMs,
|
||||
).filter { episode ->
|
||||
normalizeSeasonNumber(episode.seasonNumber) > 0
|
||||
}
|
||||
|
|
@ -148,11 +130,13 @@ fun releasedMainSeasonEpisodes(
|
|||
fun hasWatchedAllMainSeasonEpisodes(
|
||||
episodes: List<WatchingReleasedEpisode>,
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(" "))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue