Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@ 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<AdminChange>(extraBufferCapacity = 8)
val changes: SharedFlow<AdminChange> = _changes.asSharedFlow()

val itemChanged: Flow<String> = _changes.map { it.itemId }

private val _itemDeleted = MutableSharedFlow<ItemDeletedEvent>(extraBufferCapacity = 16)
val itemDeleted: SharedFlow<ItemDeletedEvent> = _itemDeleted.asSharedFlow()

fun notifyItemChanged(itemId: String) {
_changes.tryEmit(AdminChange(itemId, AdminChangeKind.METADATA))
}
Expand All @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ interface JellyseerrApiService {
@DELETE("api/v1/request/{requestId}")
suspend fun deleteRequest(@Path("requestId") requestId: Int): Response<Unit>

@DELETE("api/v1/media/{mediaId}")
suspend fun deleteMedia(@Path("mediaId") mediaId: Int): Response<Unit>

@POST("api/v1/settings/cache/flush")
suspend fun flushCache(): Response<Unit>

@POST("api/v1/settings/jobs/{jobId}/run")
suspend fun runJob(@Path("jobId") jobId: String): Response<Unit>


@POST("api/v1/request/{requestId}/approve")
suspend fun approveRequest(
@Path("requestId") requestId: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<List<AfinityMovie>>(emptyList())
val latestMovies: StateFlow<List<AfinityMovie>> = _latestMovies.asStateFlow()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ interface JellyseerrRepository {

suspend fun deleteRequest(requestId: Int): Result<Unit>

suspend fun deleteMediaAndRequestsForJellyfinItem(
jellyfinItemId: String,
tmdbId: Int? = null,
isMovie: Boolean? = null,
): Result<Unit>


suspend fun approveRequest(
requestId: Int,
serverId: Int?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AfinityItem>): List<AfinityItem>? {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -880,6 +891,129 @@ constructor(
}
}

override suspend fun deleteMediaAndRequestsForJellyfinItem(
jellyfinItemId: String,
tmdbId: Int?,
isMovie: Boolean?,
): Result<Unit> {
return withContext(Dispatchers.IO) {
val (currentServerId, currentUserId) =
activeContext ?: return@withContext Result.failure(Exception("No active session"))
try {
val api = apiService.get()

val requestsToDelete = mutableSetOf<Int>()
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?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<AfinityItem>>(emptyList())
override val latestMedia: Flow<List<AfinityItem>> = _latestMedia.asStateFlow()

Expand Down
Loading