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:
Nayif 2026-07-18 02:05:12 +05:30 committed by GitHub
commit 0cc804198a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 573 additions and 169 deletions

View file

@ -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()
}

View file

@ -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)

View file

@ -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>

View file

@ -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 }
}

View file

@ -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

View file

@ -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?
}

View file

@ -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) }

View file

@ -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(

View file

@ -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),

View file

@ -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

View file

@ -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,

View file

@ -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 {

View file

@ -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?

View file

@ -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)

View file

@ -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)
}

View file

@ -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(

View file

@ -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" },
)
}
}

View file

@ -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)

View file

@ -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

View file

@ -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(

View file

@ -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(" "))

View file

@ -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()
}
}

View file

@ -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)