From f98eef70a8383a19295d0e9ce7c7f6ae0087e19f Mon Sep 17 00:00:00 2001 From: Arnab Sadhukhan Date: Sun, 2 Aug 2026 13:00:31 +0530 Subject: [PATCH] fix(home, jellyseerr): remove deleted items immediately from home page and cleanup requests/media in Seer --- .../data/manager/AdminChangeBroadcaster.kt | 14 ++ .../data/network/JellyseerrApiService.kt | 10 ++ .../data/repository/AppDataRepository.kt | 40 +++++ .../data/repository/JellyseerrRepository.kt | 7 + .../repository/home/HomeSectionsRepository.kt | 62 ++++++++ .../jellyseerr/JellyseerrRepositoryImpl.kt | 142 +++++++++++++++++- .../media/JellyfinMediaRepository.kt | 10 ++ .../data/repository/media/MediaRepository.kt | 2 + .../afinity/ui/item/ItemDetailViewModel.kt | 6 +- .../afinity/ui/requests/RequestsViewModel.kt | 12 ++ 10 files changed, 300 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt b/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt index baefc3fc..6ce5866e 100644 --- a/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt +++ b/app/src/main/java/com/makd/afinity/data/manager/AdminChangeBroadcaster.kt @@ -16,6 +16,12 @@ enum class AdminChangeKind { data class AdminChange(val itemId: String, val kind: AdminChangeKind) +data class ItemDeletedEvent( + val itemId: String, + val tmdbId: Int? = null, + val isMovie: Boolean? = null, +) + @Singleton class AdminChangeBroadcaster @Inject constructor() { private val _changes = MutableSharedFlow(extraBufferCapacity = 8) @@ -23,6 +29,9 @@ class AdminChangeBroadcaster @Inject constructor() { val itemChanged: Flow = _changes.map { it.itemId } + private val _itemDeleted = MutableSharedFlow(extraBufferCapacity = 16) + val itemDeleted: SharedFlow = _itemDeleted.asSharedFlow() + fun notifyItemChanged(itemId: String) { _changes.tryEmit(AdminChange(itemId, AdminChangeKind.METADATA)) } @@ -34,4 +43,9 @@ class AdminChangeBroadcaster @Inject constructor() { fun notifyItemDeleted(itemId: String) { _changes.tryEmit(AdminChange(itemId, AdminChangeKind.DELETED)) } + + fun notifyItemDeleted(itemId: String, tmdbId: Int? = null, isMovie: Boolean? = null) { + _changes.tryEmit(AdminChange(itemId, AdminChangeKind.DELETED)) + _itemDeleted.tryEmit(ItemDeletedEvent(itemId, tmdbId, isMovie)) + } } \ No newline at end of file diff --git a/app/src/main/java/com/makd/afinity/data/network/JellyseerrApiService.kt b/app/src/main/java/com/makd/afinity/data/network/JellyseerrApiService.kt index fe060cb9..633a6de7 100644 --- a/app/src/main/java/com/makd/afinity/data/network/JellyseerrApiService.kt +++ b/app/src/main/java/com/makd/afinity/data/network/JellyseerrApiService.kt @@ -82,6 +82,16 @@ interface JellyseerrApiService { @DELETE("api/v1/request/{requestId}") suspend fun deleteRequest(@Path("requestId") requestId: Int): Response + @DELETE("api/v1/media/{mediaId}") + suspend fun deleteMedia(@Path("mediaId") mediaId: Int): Response + + @POST("api/v1/settings/cache/flush") + suspend fun flushCache(): Response + + @POST("api/v1/settings/jobs/{jobId}/run") + suspend fun runJob(@Path("jobId") jobId: String): Response + + @POST("api/v1/request/{requestId}/approve") suspend fun approveRequest( @Path("requestId") requestId: Int, diff --git a/app/src/main/java/com/makd/afinity/data/repository/AppDataRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/AppDataRepository.kt index 9f3ac811..6ceaddd9 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/AppDataRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/AppDataRepository.kt @@ -2,7 +2,9 @@ package com.makd.afinity.data.repository import android.content.Context import com.makd.afinity.R +import com.makd.afinity.data.manager.AdminChangeBroadcaster import com.makd.afinity.data.manager.MediaChangeManager + import com.makd.afinity.data.manager.MediaRefreshBus import com.makd.afinity.data.manager.RefreshTrigger import com.makd.afinity.data.manager.SessionManager @@ -88,6 +90,7 @@ constructor( private val musicRepository: MusicRepository, private val homeCacheRepository: HomeCacheRepository, private val homeSectionsRepository: HomeSectionsRepository, + private val adminChangeBroadcaster: AdminChangeBroadcaster, @ApplicationScope private val scope: CoroutineScope, ) { private var liveDataJob: Job? = null @@ -159,8 +162,45 @@ constructor( } } } + + scope.launch { + adminChangeBroadcaster.itemDeleted.collect { event -> + onItemDeleted(event.itemId) + } + } } + fun onItemDeleted(itemId: String) { + scope.launch { + try { + homeCacheRepository.invalidateAll() + mediaRepository.removeItemFromCache(itemId) + mediaRepository.invalidateAllCaches() + + val uuid = try { UUID.fromString(itemId) } catch (e: Exception) { null } + val matches: (UUID) -> Boolean = { id -> id.toString() == itemId || (uuid != null && id == uuid) } + + _latestMovies.update { list -> list.filterNot { matches(it.id) } } + _latestTvSeries.update { list -> list.filterNot { matches(it.id) } } + _heroCarouselItems.update { list -> list.filterNot { matches(it.id) } } + _separateMovieLibrarySections.update { list -> + list.map { (col, movies) -> col to movies.filterNot { matches(it.id) } } + } + _separateTvLibrarySections.update { list -> + list.map { (col, shows) -> col to shows.filterNot { matches(it.id) } } + } + + homeSectionsRepository.removeItem(itemId) + mediaChangeManager.notifyLibraryContentChanged("item_deleted") + + reloadHomeData() + } catch (e: Exception) { + Timber.e(e, "Error handling onItemDeleted for $itemId") + } + } + } + + private val _latestMovies = MutableStateFlow>(emptyList()) val latestMovies: StateFlow> = _latestMovies.asStateFlow() diff --git a/app/src/main/java/com/makd/afinity/data/repository/JellyseerrRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/JellyseerrRepository.kt index 8210f996..3263a734 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/JellyseerrRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/JellyseerrRepository.kt @@ -121,6 +121,13 @@ interface JellyseerrRepository { suspend fun deleteRequest(requestId: Int): Result + suspend fun deleteMediaAndRequestsForJellyfinItem( + jellyfinItemId: String, + tmdbId: Int? = null, + isMovie: Boolean? = null, + ): Result + + suspend fun approveRequest( requestId: Int, serverId: Int?, diff --git a/app/src/main/java/com/makd/afinity/data/repository/home/HomeSectionsRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/home/HomeSectionsRepository.kt index 9ef6250d..5ef802bf 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/home/HomeSectionsRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/home/HomeSectionsRepository.kt @@ -501,6 +501,68 @@ constructor( } } + fun removeItem(itemId: String) { + val uuid = try { UUID.fromString(itemId) } catch (e: Exception) { null } + val matches: (UUID) -> Boolean = { id -> id.toString() == itemId || (uuid != null && id == uuid) } + + _watchAgain.update { items -> items.filterNot { matches(it.id) } } + _criticsChoice.update { items -> items.filterNot { matches(it.id) } } + _content.update { map -> + var changed = false + val patched = map.mapValues { (_, content) -> + val newContent = removeContent(content, matches) + if (newContent !== content) changed = true + newContent + } + if (changed) patched else map + } + } + + private fun removeContent( + content: HomeSectionContent, + matches: (UUID) -> Boolean, + ): HomeSectionContent { + fun filterItems(items: List): List? { + if (items.none { matches(it.id) }) return null + return items.filterNot { matches(it.id) } + } + + return when (content) { + is HomeSectionContent.Person -> { + filterItems(content.section.items)?.let { + HomeSectionContent.Person(content.section.copy(items = it)) + } ?: content + } + is HomeSectionContent.Movie -> { + if (content.section.recommendedItems.any { matches(it.id) }) { + HomeSectionContent.Movie( + content.section.copy( + recommendedItems = content.section.recommendedItems.filterNot { matches(it.id) } + ) + ) + } else { + content + } + } + is HomeSectionContent.PersonFromMovie -> { + filterItems(content.section.items)?.let { + HomeSectionContent.PersonFromMovie(content.section.copy(items = it)) + } ?: content + } + is HomeSectionContent.Spotlight -> { + filterItems(content.items)?.let { HomeSectionContent.Spotlight(it) } ?: content + } + is HomeSectionContent.Items -> { + filterItems(content.items)?.let { HomeSectionContent.Items(it) } ?: content + } + is HomeSectionContent.RankedItems -> { + filterItems(content.items)?.let { HomeSectionContent.RankedItems(it) } ?: content + } + HomeSectionContent.Empty -> content + } + } + + suspend fun clearAllData() { buildJob?.cancel() layoutSessionKey = null diff --git a/app/src/main/java/com/makd/afinity/data/repository/jellyseerr/JellyseerrRepositoryImpl.kt b/app/src/main/java/com/makd/afinity/data/repository/jellyseerr/JellyseerrRepositoryImpl.kt index 624e581c..7051ca71 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/jellyseerr/JellyseerrRepositoryImpl.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/jellyseerr/JellyseerrRepositoryImpl.kt @@ -33,6 +33,7 @@ import com.makd.afinity.data.models.jellyseerr.TmdbKeywordSearchResponse import com.makd.afinity.data.models.jellyseerr.UserQuotaResponse import com.makd.afinity.data.models.jellyseerr.WatchProviderDetails import com.makd.afinity.data.models.jellyseerr.WatchProviderRegion +import com.makd.afinity.data.manager.AdminChangeBroadcaster import com.makd.afinity.data.models.server.AddressCheck import com.makd.afinity.data.network.JellyseerrApiService import com.makd.afinity.data.repository.JellyseerrRepository @@ -80,9 +81,11 @@ constructor( private val database: AfinityDatabase, private val networkConnectivityMonitor: NetworkConnectivityMonitor, private val addressResolver: JellyseerrAddressResolver, + private val adminChangeBroadcaster: AdminChangeBroadcaster, @ApplicationScope private val repositoryScope: CoroutineScope, ) : JellyseerrRepository { + private val jellyseerrDao = database.jellyseerrDao() private val _isAuthenticated = MutableStateFlow(false) @@ -128,6 +131,15 @@ constructor( init { repositoryScope.launch { + adminChangeBroadcaster.itemDeleted.collect { event -> + if (isAuthenticated.value) { + deleteMediaAndRequestsForJellyfinItem(event.itemId, event.tmdbId, event.isMovie) + } + } + } + + repositoryScope.launch { + networkConnectivityMonitor.isNetworkAvailable.collect { isAvailable -> if (!isAvailable) return@collect val (serverId, userId) = activeContext ?: return@collect @@ -707,15 +719,14 @@ constructor( if (response.isSuccessful && response.body() != null) { val baseRequests = response.body()!!.results - if (skip == 0 && take >= 20) { - val expiryTime = System.currentTimeMillis() - CACHE_VALIDITY_MS - jellyseerrDao.deleteExpiredRequests( - expiryTime, + if (skip == 0 && filter == null) { + jellyseerrDao.clearAllRequests( currentServerId, currentUserId.toString(), ) } + val existingById = jellyseerrDao .getAllRequests(currentServerId, currentUserId.toString()) @@ -880,6 +891,129 @@ constructor( } } + override suspend fun deleteMediaAndRequestsForJellyfinItem( + jellyfinItemId: String, + tmdbId: Int?, + isMovie: Boolean?, + ): Result { + return withContext(Dispatchers.IO) { + val (currentServerId, currentUserId) = + activeContext ?: return@withContext Result.failure(Exception("No active session")) + try { + val api = apiService.get() + + val requestsToDelete = mutableSetOf() + var targetMediaId: Int? = null + + val cleanJellyfinId = jellyfinItemId.replace("-", "").lowercase(Locale.ROOT) + + // 1. Check cached requests in DB + try { + val cachedRequests = + jellyseerrDao + .getAllRequests(currentServerId, currentUserId.toString()) + .first() + cachedRequests.forEach { reqEntity -> + if (tmdbId != null && reqEntity.tmdbId == tmdbId) { + requestsToDelete.add(reqEntity.id) + } + } + } catch (e: Exception) { + Timber.w(e, "Error checking cached requests for deletion") + } + + + // 2. Fetch remote requests list + try { + val remoteRequestsRes = api.getRequests(take = 1000) + if (remoteRequestsRes.isSuccessful) { + remoteRequestsRes.body()?.results?.forEach { req -> + val jId = req.media.jellyfinMediaId?.replace("-", "")?.lowercase(Locale.ROOT) + val jId4k = req.media.jellyfinMediaId4k?.replace("-", "")?.lowercase(Locale.ROOT) + if (cleanJellyfinId == jId || cleanJellyfinId == jId4k || (tmdbId != null && req.media.tmdbId == tmdbId)) { + requestsToDelete.add(req.id) + if (req.media.id > 0) { + targetMediaId = req.media.id + } + } + } + } + } catch (e: Exception) { + Timber.w(e, "Error fetching remote requests for deletion check") + } + + // 3. Lookup mediaDetails if targetMediaId wasn't found from requests but tmdbId is known + if (targetMediaId == null && tmdbId != null && isMovie != null) { + try { + val detailsRes = if (isMovie) api.getMovieDetails(tmdbId) else api.getTvDetails(tmdbId) + if (detailsRes.isSuccessful) { + detailsRes.body()?.mediaInfo?.let { mediaInfo -> + if (mediaInfo.id > 0) { + targetMediaId = mediaInfo.id + } + mediaInfo.requests?.forEach { req -> + requestsToDelete.add(req.id) + } + } + } + } catch (e: Exception) { + Timber.w(e, "Error fetching media details for TMDB $tmdbId") + } + } + + // 4. Delete requests in Jellyseerr server and local DB + requestsToDelete.forEach { requestId -> + try { + api.deleteRequest(requestId) + } catch (e: Exception) { + Timber.w(e, "Failed to delete request $requestId in Jellyseerr") + } + jellyseerrDao.deleteRequest( + requestId, + currentServerId, + currentUserId.toString(), + ) + } + + // 5. Delete media in Jellyseerr server + targetMediaId?.let { mediaId -> + try { + val mediaDelRes = api.deleteMedia(mediaId) + Timber.d("Delete media $mediaId in Jellyseerr response: ${mediaDelRes.code()}") + } catch (e: Exception) { + Timber.w(e, "Failed to delete media $mediaId in Jellyseerr") + } + } + + // 6. Trigger clear data / cache flush in Jellyseerr server + try { + api.flushCache() + } catch (e: Exception) { + Timber.w(e, "Failed to flush cache in Jellyseerr") + } + try { + api.runJob("clear-data") + } catch (e: Exception) { + Timber.w(e, "Failed to run clear-data job in Jellyseerr") + } + try { + api.runJob("availability-sync") + } catch (e: Exception) { + Timber.w(e, "Failed to run availability-sync job in Jellyseerr") + } + + // 7. Refresh requests + getRequests(take = 50, skip = 0) + + Result.success(Unit) + } catch (e: Exception) { + Timber.e(e, "Error deleting media and requests in Jellyseerr for $jellyfinItemId") + Result.failure(e) + } + } + } + + override suspend fun approveRequest( requestId: Int, serverId: Int?, diff --git a/app/src/main/java/com/makd/afinity/data/repository/media/JellyfinMediaRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/media/JellyfinMediaRepository.kt index 78325d5e..749bacb1 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/media/JellyfinMediaRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/media/JellyfinMediaRepository.kt @@ -374,6 +374,16 @@ constructor( _latestMedia.value = emptyList() } + override fun removeItemFromCache(itemId: String) { + val uuid = try { UUID.fromString(itemId) } catch (e: Exception) { null } + val matches: (UUID) -> Boolean = { id -> id.toString() == itemId || (uuid != null && id == uuid) } + + _latestMedia.update { list -> list.filterNot { matches(it.id) } } + _continueWatching.update { list -> list.filterNot { matches(it.id) } } + _nextUp.update { list -> list.filterNot { matches(it.id) || matches(it.seriesId) } } + } + + private val _latestMedia = MutableStateFlow>(emptyList()) override val latestMedia: Flow> = _latestMedia.asStateFlow() diff --git a/app/src/main/java/com/makd/afinity/data/repository/media/MediaRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/media/MediaRepository.kt index feac0eda..0831dbc1 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/media/MediaRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/media/MediaRepository.kt @@ -47,6 +47,8 @@ interface MediaRepository { fun patchItemImages(updatedItem: AfinityItem) + fun removeItemFromCache(itemId: String) + suspend fun invalidateItemCache(itemId: UUID) suspend fun refreshItemUserData(itemId: UUID, fields: List? = null): AfinityItem? diff --git a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailViewModel.kt b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailViewModel.kt index 4e10738d..8de8b070 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailViewModel.kt @@ -1594,9 +1594,12 @@ constructor( onError: (String) -> Unit, ) { viewModelScope.launch { + val item = uiState.value.item + val tmdbId = item?.providerIds?.get("Tmdb")?.toIntOrNull() + val isMovie = item is AfinityMovie val result = adminRepository.deleteItem(targetItemId.toString()) if (result.isSuccess) { - adminChangeBroadcaster.notifyItemChanged(targetItemId.toString()) + adminChangeBroadcaster.notifyItemDeleted(targetItemId.toString(), tmdbId, isMovie) onSuccess() } else { val errorMsg = result.exceptionOrNull()?.localizedMessage ?: "Unknown error" @@ -1604,6 +1607,7 @@ constructor( } } } + } data class ItemDetailUiState( diff --git a/app/src/main/java/com/makd/afinity/ui/requests/RequestsViewModel.kt b/app/src/main/java/com/makd/afinity/ui/requests/RequestsViewModel.kt index 18d05ebb..e156f360 100644 --- a/app/src/main/java/com/makd/afinity/ui/requests/RequestsViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/requests/RequestsViewModel.kt @@ -25,6 +25,7 @@ import com.makd.afinity.data.models.jellyseerr.SonarrSeries import com.makd.afinity.data.models.jellyseerr.Studio import com.makd.afinity.data.models.jellyseerr.UserQuotaResponse import com.makd.afinity.data.models.jellyseerr.hasPermission +import com.makd.afinity.data.manager.AdminChangeBroadcaster import com.makd.afinity.data.repository.JellyseerrRepository import com.makd.afinity.util.GenreDuotoneColorGenerator import dagger.hilt.android.lifecycle.HiltViewModel @@ -51,8 +52,10 @@ class RequestsViewModel constructor( @param:ApplicationContext private val context: Context, private val jellyseerrRepository: JellyseerrRepository, + private val adminChangeBroadcaster: AdminChangeBroadcaster, ) : ViewModel() { + private val _uiState = MutableStateFlow(RequestsUiState(isLoading = true, isLoadingDiscover = true)) val uiState: StateFlow = _uiState.asStateFlow() @@ -156,8 +159,17 @@ constructor( } } observeRequestEvents() + viewModelScope.launch { + adminChangeBroadcaster.itemDeleted.collect { + jellyfinIdCache.clear() + fetchingIds.clear() + loadRequests() + loadDiscoverContent() + } + } } + private fun loadCurrentUser() { viewModelScope.launch { jellyseerrRepository