Merge pull request #1631 from skoruppa/feat/simkl-anime-tracking

fix: Simkl anime - prevent show wipe, reactive CW update, next episode resolution
This commit is contained in:
Nayif 2026-07-27 21:53:57 +05:30 committed by GitHub
commit 4ba3df8b4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 45 additions and 10 deletions

View file

@ -568,12 +568,13 @@ fun MetaDetailsScreen(
val movieProgress = progressByVideoId[meta.id]
?.takeUnless { it.isCompleted }
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) {
val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode, watchedUiState.watchedKeys) {
meta.seriesPrimaryAction(
entries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = cwPrefs.upNextFromFurthestEpisode,
watchedKeys = watchedUiState.watchedKeys,
)
}
val seriesActionVideo = remember(seriesAction, meta.id, meta.videos) {

View file

@ -2,6 +2,8 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
import com.nuvio.app.features.watching.domain.WatchingContentRef
@ -144,15 +146,33 @@ internal fun MetaDetails.seriesPrimaryAction(
todayIsoDate: String,
preferFurthestEpisode: Boolean = true,
showUnairedNextUp: Boolean = false,
): SeriesPrimaryAction? =
seriesPrimaryAction(
content = WatchingContentRef(type = type, id = id),
watchedKeys: Set<String> = emptySet(),
): SeriesPrimaryAction? {
val content = WatchingContentRef(type = type, id = id)
val effectiveWatchedItems = buildList {
addAll(watchedItems.filter { it.type.equals(type, ignoreCase = true) && it.id.equals(id, ignoreCase = true) })
if (watchedKeys.isNotEmpty()) {
val existingKeys = mapTo(mutableSetOf()) { watchedItemKey(it.type, it.id, it.season, it.episode) }
videos.forEach { video ->
val season = video.season ?: return@forEach
val episode = video.episode ?: return@forEach
val key = watchedItemKey(type, id, season, episode)
if (key in existingKeys) return@forEach
if (WatchingState.isEpisodeWatched(watchedKeys, type, id, video)) {
add(WatchedItem(id = id, type = type, season = season, episode = episode, name = "", markedAtEpochMs = 0L))
}
}
}
}
return seriesPrimaryAction(
content = content,
entries = entries,
watchedItems = watchedItems,
watchedItems = effectiveWatchedItems,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = preferFurthestEpisode,
showUnairedNextUp = showUnairedNextUp,
)
}
internal fun MetaDetails.seriesPrimaryAction(
content: WatchingContentRef,

View file

@ -100,11 +100,13 @@ object SimklWatchedSyncAdapter : TrackingWatchedProvider {
override suspend fun delete(profileId: Int, items: Collection<WatchedItem>) {
if (profileId != ProfileRepository.activeProfileId || items.isEmpty()) return
val episodeItems = items.filter { item -> item.season != null && item.episode != null }
if (episodeItems.isEmpty()) return
// Optimistically mark video IDs as removed so fallback won't show them as watched
items.forEach { item -> item.videoId?.let(SimklAnimeWatchedFallback::markOptimisticallyRemoved) }
episodeItems.forEach { item -> item.videoId?.let(SimklAnimeWatchedFallback::markOptimisticallyRemoved) }
SimklSyncRepository.ensureLoaded()
val snapshot = SimklSyncRepository.state.value.snapshot
val media = items.map { item ->
val media = episodeItems.map { item ->
snapshot.mediaReference(
contentId = item.id,
contentType = item.type,

View file

@ -818,6 +818,7 @@ object WatchedRepository {
} else if (source.providerId != null) {
// Items not found in local store (e.g. anime resolved via snapshot fallback).
// Still push delete to provider so it can remove from remote.
// The observer on snapshot changes will re-pull items and update the store.
pushDeleteToServer(items = items.toList(), source = source)
}
}
@ -1010,7 +1011,8 @@ object WatchedRepository {
/**
* Observes provider extra watched keys (e.g. Simkl anime alternate IDs).
* When the provider's snapshot changes (after mutations, syncs), recomputes
* extra keys and re-publishes so watchedKeys stays reactive and current.
* extra keys, re-pulls watched items, and re-publishes so watchedKeys and
* items stay reactive and current.
*/
private fun startExtraKeysObserverIfNeeded() {
if (extraKeysObserverJob != null) return
@ -1021,9 +1023,19 @@ object WatchedRepository {
adapter.observeExtraWatchedKeys(currentProfileId)
.distinctUntilChanged()
.collectLatest { extraKeys ->
val changed = providerExtraWatchedKeys[providerId] != extraKeys
if (changed) {
val keysChanged = providerExtraWatchedKeys[providerId] != extraKeys
if (keysChanged) {
providerExtraWatchedKeys[providerId] = extraKeys
// Re-pull items from provider to reflect snapshot changes
// (e.g. after remote episode removal)
val freshItems = adapter.pull(
profileId = currentProfileId,
pageSize = watchedItemsPageSize,
)
val itemsByKey = freshItems.associateBy { item ->
watchedItemKey(item.type, item.id, item.season, item.episode)
}.toMutableMap()
providerItemsByKey[providerId] = itemsByKey
publish()
}
}