mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-19 05:45:41 +00:00
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:
parent
af7099199b
commit
3acbe6f2da
7 changed files with 405 additions and 37 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue