fix(NUVIO-MOBILE-WZ): compact watched persistence to prevent OOM

Fixes NUVIO-MOBILE-WZ

Fixes NUVIO-MOBILE-WX

Fixes NUVIO-MOBILE-QV
This commit is contained in:
tapframe 2026-08-10 01:30:59 +05:30
parent af7099199b
commit 3acbe6f2da
7 changed files with 405 additions and 37 deletions

View file

@ -2,11 +2,41 @@ package com.nuvio.app.features.watched
import kotlinx.serialization.Serializable
@Serializable
internal data class StoredWatchedItemEntry(
val season: Int? = null,
val episode: Int? = null,
val videoId: String? = null,
val markedAtEpochMs: Long = 0L,
)
@Serializable
internal data class StoredWatchedItemGroup(
val id: String,
val type: String,
val name: String,
val poster: String? = null,
val releaseInfo: String? = null,
val trackingProviderId: String? = null,
val trackingProviderItemId: String? = null,
val trackingSourceUrl: String? = null,
val entries: List<StoredWatchedItemEntry> = emptyList(),
)
@Serializable
internal data class StoredWatchedAliasGroup(
val type: String,
val ids: List<String> = emptyList(),
val seasons: Map<Int, List<Int>> = emptyMap(),
)
@Serializable
internal data class StoredProviderWatchedPayload(
val items: List<WatchedItem> = emptyList(),
val itemGroups: List<StoredWatchedItemGroup> = emptyList(),
val fullyWatchedSeriesKeys: Set<String> = emptySet(),
val extraWatchedKeys: Set<String> = emptySet(),
val extraWatchedKeyGroups: List<StoredWatchedAliasGroup> = emptyList(),
val dirtyWatchedKeys: Set<String> = emptySet(),
)
@ -21,3 +51,177 @@ internal data class StoredWatchedPayload(
val dirtyWatchedKeys: Set<String> = emptySet(),
val providerPayloads: Map<String, StoredProviderWatchedPayload> = emptyMap(),
)
internal fun compactProviderWatchedItems(items: Collection<WatchedItem>): List<StoredWatchedItemGroup> =
items
.groupBy { item ->
StoredWatchedItemGroupKey(
id = item.id,
type = item.type,
name = item.name,
poster = item.poster,
releaseInfo = item.releaseInfo,
trackingProviderId = item.trackingProviderId,
trackingProviderItemId = item.trackingProviderItemId,
trackingSourceUrl = item.trackingSourceUrl,
)
}
.map { (key, groupedItems) ->
StoredWatchedItemGroup(
id = key.id,
type = key.type,
name = key.name,
poster = key.poster,
releaseInfo = key.releaseInfo,
trackingProviderId = key.trackingProviderId,
trackingProviderItemId = key.trackingProviderItemId,
trackingSourceUrl = key.trackingSourceUrl,
entries = groupedItems
.map { item ->
StoredWatchedItemEntry(
season = item.season,
episode = item.episode,
videoId = item.videoId,
markedAtEpochMs = item.markedAtEpochMs,
)
}
.sortedWith(
compareBy<StoredWatchedItemEntry>(
{ it.season ?: -1 },
{ it.episode ?: -1 },
{ it.videoId.orEmpty() },
{ it.markedAtEpochMs },
),
),
)
}
.sortedWith(compareBy(StoredWatchedItemGroup::type, StoredWatchedItemGroup::id, StoredWatchedItemGroup::name))
internal fun expandProviderWatchedItems(groups: Collection<StoredWatchedItemGroup>): List<WatchedItem> =
groups.flatMap { group ->
group.entries.map { entry ->
WatchedItem(
id = group.id,
type = group.type,
name = group.name,
poster = group.poster,
releaseInfo = group.releaseInfo,
season = entry.season,
episode = entry.episode,
videoId = entry.videoId,
trackingProviderId = group.trackingProviderId,
trackingProviderItemId = group.trackingProviderItemId,
trackingSourceUrl = group.trackingSourceUrl,
markedAtEpochMs = entry.markedAtEpochMs,
)
}
}
internal fun compactExtraWatchedKeys(keys: Collection<String>): List<StoredWatchedAliasGroup> {
val seasonsByContent = linkedMapOf<WatchedAliasContentKey, MutableMap<Int, MutableSet<Int>>>()
keys.forEach { key ->
val parsed = parseStoredWatchedKey(key) ?: return@forEach
seasonsByContent
.getOrPut(WatchedAliasContentKey(type = parsed.type, id = parsed.id), ::linkedMapOf)
.getOrPut(parsed.season, ::linkedSetOf)
.add(parsed.episode)
}
val idsBySignature = linkedMapOf<WatchedAliasSignature, MutableSet<String>>()
seasonsByContent.forEach { (content, seasons) ->
val normalizedSeasons = seasons
.entries
.sortedBy { (season, _) -> season }
.associate { (season, episodes) -> season to episodes.sorted() }
idsBySignature
.getOrPut(
WatchedAliasSignature(type = content.type, seasons = normalizedSeasons),
::linkedSetOf,
)
.add(content.id)
}
return idsBySignature
.map { (signature, ids) ->
StoredWatchedAliasGroup(
type = signature.type,
ids = ids.sorted(),
seasons = signature.seasons,
)
}
.sortedWith(
compareBy<StoredWatchedAliasGroup>(
StoredWatchedAliasGroup::type,
{ it.ids.firstOrNull().orEmpty() },
),
)
}
internal fun expandExtraWatchedKeys(groups: Collection<StoredWatchedAliasGroup>): Set<String> = buildSet {
groups.forEach { group ->
group.ids.forEach { id ->
group.seasons.forEach { (season, episodes) ->
episodes.forEach { episode ->
add(
watchedItemKey(
type = group.type,
id = id,
season = season.takeUnless { it == -1 },
episode = episode.takeUnless { it == -1 },
),
)
}
}
}
}
}
private data class StoredWatchedItemGroupKey(
val id: String,
val type: String,
val name: String,
val poster: String?,
val releaseInfo: String?,
val trackingProviderId: String?,
val trackingProviderItemId: String?,
val trackingSourceUrl: String?,
)
private data class WatchedAliasContentKey(
val type: String,
val id: String,
)
private data class WatchedAliasSignature(
val type: String,
val seasons: Map<Int, List<Int>>,
)
private data class ParsedStoredWatchedKey(
val type: String,
val id: String,
val season: Int,
val episode: Int,
)
private fun parseStoredWatchedKey(key: String): ParsedStoredWatchedKey? {
val episodeSeparator = key.lastIndexOf(':')
if (episodeSeparator <= 0) return null
val seasonSeparator = key.lastIndexOf(':', episodeSeparator - 1)
if (seasonSeparator <= 0) return null
val typeSeparator = key.indexOf(':')
if (typeSeparator <= 0 || typeSeparator >= seasonSeparator) return null
val type = key.substring(0, typeSeparator)
val id = key.substring(typeSeparator + 1, seasonSeparator)
val season = key.substring(seasonSeparator + 1, episodeSeparator).toIntOrNull() ?: return null
val episode = key.substring(episodeSeparator + 1).toIntOrNull() ?: return null
if (id.isBlank()) return null
return ParsedStoredWatchedKey(
type = type,
id = id,
season = season,
episode = episode,
)
}

View file

@ -55,6 +55,7 @@ suspend fun resolveWatchedBadgesBulk(
withContext(Dispatchers.Default) {
val semaphore = Semaphore(BADGE_RESOLUTION_CONCURRENCY)
val resolvedIds = mutableSetOf<String>()
val resolvedStates = linkedMapOf<String, Boolean>()
for (contentId in touchedSeriesIds) {
semaphore.withPermit {
@ -66,12 +67,12 @@ suspend fun resolveWatchedBadgesBulk(
null
}
if (meta != null) {
WatchedRepository.reconcileFullyWatchedSeriesState(
val isFullyWatched = WatchedRepository.calculateFullyWatchedSeriesState(
meta = meta,
todayIsoDate = todayIsoDate,
isEpisodeWatched = { episode ->
val key = watchedItemKey(meta.type, meta.id, episode.season, episode.episode)
if (key in watchedKeys) {
val keys = watchedItemKeys(meta.type, meta.id, episode.season, episode.episode)
if (keys.any(watchedKeys::contains)) {
true
} else {
val episodeNumber = episode.episode
@ -89,12 +90,14 @@ suspend fun resolveWatchedBadgesBulk(
}
},
)
resolvedStates[watchedItemKey(meta.type, meta.id)] = isFullyWatched
resolvedIds.add(contentId)
}
}
yield()
}
WatchedRepository.updateFullyWatchedSeriesStates(resolvedStates)
log.i { "Bulk badge resolution complete: resolved ${resolvedIds.size}/${touchedSeriesIds.size}" }
// Sibling expansion

View file

@ -103,6 +103,11 @@ internal fun extraWatchedKeysChanged(
current: Set<String>,
): Boolean = previous.orEmpty() != current
private const val maxRestorableWatchedPayloadChars = 4 * 1024 * 1024
internal fun shouldRestoreWatchedPayload(payloadLength: Int): Boolean =
payloadLength <= maxRestorableWatchedPayloadChars
object WatchedRepository {
private data class WatchedRefreshOperation(
val profileId: Int,
@ -122,7 +127,7 @@ object WatchedRepository {
private val log = Logger.withTag("WatchedRepository")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
encodeDefaults = false
}
private val _uiState = MutableStateFlow(WatchedUiState())
@ -229,9 +234,14 @@ object WatchedRepository {
val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim()
if (payload.isNotEmpty()) {
val storedPayload = runCatching {
json.decodeFromString<StoredWatchedPayload>(payload)
}.getOrDefault(StoredWatchedPayload())
val storedPayload = if (shouldRestoreWatchedPayload(payload.length)) {
runCatching {
json.decodeFromString<StoredWatchedPayload>(payload)
}.getOrDefault(StoredWatchedPayload())
} else {
WatchedStorage.savePayload(profileId, "")
StoredWatchedPayload()
}
lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs
deltaCursorEventId = storedPayload.deltaCursorEventId
deltaInitialized = storedPayload.deltaInitialized
@ -245,7 +255,7 @@ object WatchedRepository {
nuvioItems.putAll(restoredItems)
dirtyNuvioKeys += storedPayload.dirtyWatchedKeys.filter { key -> key in restoredItems }
restoredProviderPayloads.forEach { (providerId, providerPayload) ->
val providerItemsByKey = providerPayload.items
val providerItemsByKey = (providerPayload.items + expandProviderWatchedItems(providerPayload.itemGroups))
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
providerItems[providerId] = providerItemsByKey.toMutableMap()
@ -257,7 +267,8 @@ object WatchedRepository {
expandedSiblingKeys = storedPayload.expandedSiblingKeys
restoredProviderPayloads.forEach { (providerId, providerPayload) ->
providerFullyWatchedSeriesKeys[providerId] = providerPayload.fullyWatchedSeriesKeys
providerExtraWatchedKeys[providerId] = providerPayload.extraWatchedKeys
providerExtraWatchedKeys[providerId] =
providerPayload.extraWatchedKeys + expandExtraWatchedKeys(providerPayload.extraWatchedKeyGroups)
}
loadedProviders += restoredProviderPayloads.keys
} else {
@ -923,8 +934,8 @@ object WatchedRepository {
meta: MetaDetails,
todayIsoDate: String,
isEpisodeWatched: (MetaVideo) -> Boolean = { episode ->
val key = watchedItemKey(meta.type, meta.id, episode.season, episode.episode)
if (key in _uiState.value.watchedKeys) {
val keys = watchedItemKeys(meta.type, meta.id, episode.season, episode.episode)
if (keys.any(_uiState.value.watchedKeys::contains)) {
true
} else {
val episodeNumber = episode.episode
@ -939,17 +950,32 @@ object WatchedRepository {
): Boolean {
if (!meta.type.isSeriesLikeWatchedType()) return false
ensureLoaded()
val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode ->
isEpisodeWatched(episode) || isEpisodeCompleted(episode)
}
updateFullyWatchedSeriesKey(
key = watchedItemKey(meta.type, meta.id),
isFullyWatched = shouldMarkSeriesWatched,
val shouldMarkSeriesWatched = calculateFullyWatchedSeriesState(
meta = meta,
todayIsoDate = todayIsoDate,
isEpisodeWatched = isEpisodeWatched,
isEpisodeCompleted = isEpisodeCompleted,
)
updateFullyWatchedSeriesStates(
mapOf(watchedItemKey(meta.type, meta.id) to shouldMarkSeriesWatched),
)
return shouldMarkSeriesWatched
}
internal fun calculateFullyWatchedSeriesState(
meta: MetaDetails,
todayIsoDate: String,
isEpisodeWatched: (MetaVideo) -> Boolean,
isEpisodeCompleted: (MetaVideo) -> Boolean,
): Boolean {
if (!meta.type.isSeriesLikeWatchedType()) return false
ensureLoaded()
return meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode ->
isEpisodeWatched(episode) || isEpisodeCompleted(episode)
}
}
fun updateFullyWatchedSeries(
id: String,
type: String,
@ -967,9 +993,19 @@ object WatchedRepository {
key: String,
isFullyWatched: Boolean,
) {
updateFullyWatchedSeriesStates(mapOf(key to isFullyWatched))
}
internal fun updateFullyWatchedSeriesStates(states: Map<String, Boolean>) {
if (states.isEmpty()) return
ensureLoaded()
val source = activeSource
val current = fullyWatchedSeriesKeysForSource(source)
val updated = if (isFullyWatched) current + key else current - key
val updated = current.toMutableSet().apply {
states.forEach { (key, isFullyWatched) ->
if (isFullyWatched) add(key) else remove(key)
}
}
if (updated == current) return
setFullyWatchedSeriesKeysForSource(source = source, keys = updated)
publish()
@ -977,6 +1013,7 @@ object WatchedRepository {
}
fun setExpandedFullyWatchedSeriesKeys(keys: Set<String>) {
if (expandedSiblingKeys == keys) return
expandedSiblingKeys = keys
publish()
persist()
@ -1164,14 +1201,16 @@ object WatchedRepository {
deltaInitialized = deltaInitialized,
dirtyWatchedKeys = dirtyNuvioKeys.toSet(),
providerPayloads = providerIds.associate { providerId ->
val items = providerItems[providerId]
.orEmpty()
.values
.map(WatchedItem::normalizedMarkedAt)
providerId.storageId to StoredProviderWatchedPayload(
items = providerItems[providerId]
.orEmpty()
.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs },
itemGroups = compactProviderWatchedItems(items),
fullyWatchedSeriesKeys = providerFullyWatchedSeriesKeys[providerId].orEmpty(),
extraWatchedKeys = providerExtraWatchedKeys[providerId].orEmpty(),
extraWatchedKeyGroups = compactExtraWatchedKeys(
providerExtraWatchedKeys[providerId].orEmpty(),
),
dirtyWatchedKeys = dirtyProviderKeys[providerId].orEmpty(),
)
},

View file

@ -2,7 +2,6 @@ package com.nuvio.app.features.watching.sync
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watched.watchedItemTypeAliases
internal data class TraktWatchedProjectionCandidate(
val item: WatchedItem,
@ -51,11 +50,9 @@ internal fun buildTraktWatchedProjection(
candidates.forEach { candidate ->
val item = candidate.item
val storedKey = watchedItemKey(item.type, item.id, item.season, item.episode)
watchedItemTypeAliases(item.type).forEach { type ->
candidate.contentIds.forEach { contentId ->
val key = watchedItemKey(type, contentId, item.season, item.episode)
if (key != storedKey) add(key)
}
candidate.contentIds.forEach { contentId ->
val key = watchedItemKey(item.type, contentId, item.season, item.episode)
if (key != storedKey) add(key)
}
}
}

View file

@ -12,6 +12,10 @@ class StoredWatchedPayloadTest {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val compactJson = Json {
ignoreUnknownKeys = true
encodeDefaults = false
}
@Test
fun providerSnapshot_roundTripsWatchedState() {
@ -42,4 +46,112 @@ class StoredWatchedPayloadTest {
assertTrue(restored.providerPayloads.isEmpty())
}
@Test
fun compactProviderItems_roundTripWithoutLosingMetadata() {
val items = listOf(
WatchedItem(
id = "tt1234567",
type = "series",
name = "Show",
poster = "poster",
releaseInfo = "2026",
season = 1,
episode = 1,
videoId = "video-1",
trackingProviderId = "trakt",
trackingProviderItemId = "123",
trackingSourceUrl = "https://trakt.tv/shows/123",
markedAtEpochMs = 1_000L,
),
WatchedItem(
id = "tt1234567",
type = "series",
name = "Show",
poster = "poster",
releaseInfo = "2026",
season = 1,
episode = 2,
videoId = "video-2",
trackingProviderId = "trakt",
trackingProviderItemId = "123",
trackingSourceUrl = "https://trakt.tv/shows/123",
markedAtEpochMs = 2_000L,
),
)
val restored = expandProviderWatchedItems(compactProviderWatchedItems(items))
assertEquals(items.toSet(), restored.toSet())
}
@Test
fun compactExtraKeys_roundTripIdsContainingColons() {
val keys = setOf(
watchedItemKey("series", "tmdb:123", 1, 1),
watchedItemKey("series", "tmdb:123", 1, 2),
watchedItemKey("series", "trakt:456", 1, 1),
watchedItemKey("movie", "tmdb:789"),
)
val restored = expandExtraWatchedKeys(compactExtraWatchedKeys(keys))
assertEquals(keys, restored)
}
@Test
fun compactProviderSnapshot_avoidsExpandedAliasPayloadGrowth() {
val items = buildList {
repeat(10) { showIndex ->
repeat(10) { seasonIndex ->
repeat(10) { episodeIndex ->
add(
WatchedItem(
id = "tt${showIndex.toString().padStart(7, '0')}",
type = "series",
name = "Show $showIndex",
season = seasonIndex + 1,
episode = episodeIndex + 1,
markedAtEpochMs = 1_000L + episodeIndex,
),
)
}
}
}
}
val extraKeys = buildSet {
items.forEach { item ->
val showIndex = item.id.removePrefix("tt").toInt()
val contentIds = listOf(
item.id,
"tmdb:${showIndex + 100}",
"tvdb:${showIndex + 200}",
"trakt:${showIndex + 300}",
"show-$showIndex",
)
watchedItemTypeAliases(item.type).forEach { type ->
contentIds.forEach { contentId ->
val key = watchedItemKey(type, contentId, item.season, item.episode)
val storedKey = watchedItemKey(item.type, item.id, item.season, item.episode)
if (key != storedKey) add(key)
}
}
}
}
val legacy = StoredProviderWatchedPayload(
items = items,
extraWatchedKeys = extraKeys,
)
val compact = StoredProviderWatchedPayload(
itemGroups = compactProviderWatchedItems(items),
extraWatchedKeyGroups = compactExtraWatchedKeys(extraKeys),
)
val legacySize = json.encodeToString(legacy).length
val compactSize = compactJson.encodeToString(compact).length
assertEquals(items.toSet(), expandProviderWatchedItems(compact.itemGroups).toSet())
assertEquals(extraKeys, expandExtraWatchedKeys(compact.extraWatchedKeyGroups))
assertTrue(compactSize * 8 < legacySize, "compact=$compactSize legacy=$legacySize")
}
}

View file

@ -14,6 +14,12 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
class WatchedRepositoryTest {
@Test
fun oversizedLegacyPayload_isNotRestored() {
assertTrue(shouldRestoreWatchedPayload(4 * 1024 * 1024))
assertFalse(shouldRestoreWatchedPayload(4 * 1024 * 1024 + 1))
}
@Test
fun emptyProviderExtraKeys_doNotTriggerInitialRefresh() {
assertFalse(extraWatchedKeysChanged(previous = null, current = emptySet()))

View file

@ -8,6 +8,7 @@ import com.nuvio.app.features.trakt.TraktWatchedHttpException
import com.nuvio.app.features.trakt.TraktWatchedPageClient
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watched.watchedItemKeys
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
@ -33,7 +34,7 @@ class TraktWatchedSyncAdapterTest {
}
@Test
fun `movie projection emits poster keys for every Trakt identity`() {
fun `movie projection emits one canonical key for every Trakt identity`() {
val item = watchedItem(id = "tt1234567", type = "movie")
val projection = buildTraktWatchedProjection(
@ -49,12 +50,15 @@ class TraktWatchedSyncAdapterTest {
assertTrue(watchedItemKey("movie", "tmdb:123") in projection.extraWatchedKeys)
assertTrue(watchedItemKey("movie", "trakt:789") in projection.extraWatchedKeys)
assertTrue(watchedItemKey("movie", "example-movie") in projection.extraWatchedKeys)
assertTrue(watchedItemKey("film", "tt1234567") in projection.extraWatchedKeys)
assertFalse(watchedItemKey("film", "tt1234567") in projection.extraWatchedKeys)
assertFalse(watchedItemKey("movie", "tt1234567") in projection.extraWatchedKeys)
assertTrue(
watchedItemKeys("film", "tmdb:123").any(projection.extraWatchedKeys::contains),
)
}
@Test
fun `episode projection emits aliases with matching coordinates and content types`() {
fun `episode projection emits canonical aliases with matching coordinates`() {
val projection = buildTraktWatchedProjection(
listOf(
TraktWatchedProjectionCandidate(
@ -70,9 +74,12 @@ class TraktWatchedSyncAdapterTest {
)
assertTrue(watchedItemKey("series", "tmdb:321", 2, 4) in projection.extraWatchedKeys)
assertTrue(watchedItemKey("tv", "tt7654321", 2, 4) in projection.extraWatchedKeys)
assertTrue(watchedItemKey("show", "trakt:987", 2, 4) in projection.extraWatchedKeys)
assertTrue(watchedItemKey("tvshow", "example-show", 2, 4) in projection.extraWatchedKeys)
assertTrue(watchedItemKey("series", "trakt:987", 2, 4) in projection.extraWatchedKeys)
assertTrue(watchedItemKey("series", "example-show", 2, 4) in projection.extraWatchedKeys)
assertFalse(watchedItemKey("tv", "tt7654321", 2, 4) in projection.extraWatchedKeys)
assertTrue(
watchedItemKeys("tvshow", "example-show", 2, 4).any(projection.extraWatchedKeys::contains),
)
}
@Test