From 1646a664c4855bba752c7292f87428a7f2d15a56 Mon Sep 17 00:00:00 2001 From: Arnab Sadhukhan Date: Wed, 22 Jul 2026 21:59:49 +0530 Subject: [PATCH] feat(feature): add delete item option for Jellyfin admin accounts --- .../data/repository/admin/AdminRepository.kt | 3 + .../admin/JellyfinAdminRepository.kt | 21 ++++ .../makd/afinity/ui/item/ItemDetailScreen.kt | 102 ++++++++++++++++++ .../afinity/ui/item/ItemDetailViewModel.kt | 19 ++++ .../item/components/EpisodeDetailOverlay.kt | 20 ++++ .../components/shared/ActionButtonsRow.kt | 20 ++++ .../ui/item/components/shared/AdminAction.kt | 1 + app/src/main/res/values/strings.xml | 5 + 8 files changed, 191 insertions(+) diff --git a/app/src/main/java/com/makd/afinity/data/repository/admin/AdminRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/admin/AdminRepository.kt index 90148287..04028501 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/admin/AdminRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/admin/AdminRepository.kt @@ -67,4 +67,7 @@ interface AdminRepository { replaceAllMetadata: Boolean, replaceAllImages: Boolean, ): Result + + suspend fun deleteItem(itemId: String): Result } + diff --git a/app/src/main/java/com/makd/afinity/data/repository/admin/JellyfinAdminRepository.kt b/app/src/main/java/com/makd/afinity/data/repository/admin/JellyfinAdminRepository.kt index 28246160..96c15ade 100644 --- a/app/src/main/java/com/makd/afinity/data/repository/admin/JellyfinAdminRepository.kt +++ b/app/src/main/java/com/makd/afinity/data/repository/admin/JellyfinAdminRepository.kt @@ -15,6 +15,7 @@ import org.jellyfin.sdk.api.operations.ImageApi import org.jellyfin.sdk.api.operations.ItemLookupApi import org.jellyfin.sdk.api.operations.ItemRefreshApi import org.jellyfin.sdk.api.operations.ItemUpdateApi +import org.jellyfin.sdk.api.operations.LibraryApi import org.jellyfin.sdk.api.operations.RemoteImageApi import org.jellyfin.sdk.api.operations.UserLibraryApi import org.jellyfin.sdk.model.FileInfo @@ -476,6 +477,26 @@ constructor( } } + override suspend fun deleteItem(itemId: String): Result = + withContext(Dispatchers.IO) { + try { + val apiClient = + getApiClient() + ?: return@withContext Result.failure( + IllegalStateException("No API client") + ) + LibraryApi(apiClient).deleteItem(itemId = UUID.fromString(itemId)) + adminChangeBroadcaster.notifyItemChanged(itemId) + Result.success(Unit) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to delete item $itemId") + Result.failure(e) + } catch (e: Exception) { + Timber.e(e, "Unexpected error deleting item $itemId") + Result.failure(e) + } + } + private fun BaseItemDto.toEditableItem(availableRatings: List): EditableItem = EditableItem( id = id.toString(), diff --git a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt index dca606e7..aab4a544 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/ItemDetailScreen.kt @@ -5,6 +5,7 @@ package com.makd.afinity.ui.item import android.content.Context import android.content.res.Configuration import androidx.annotation.OptIn +import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -26,9 +27,14 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import java.util.UUID import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -131,6 +137,7 @@ fun ItemDetailScreen( val canDownload by viewModel.canDownload.collectAsStateWithLifecycle() val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle() var showEpisodeRefreshDialog by remember { mutableStateOf(false) } + var showEpisodeDeleteDialog by remember { mutableStateOf(false) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { @@ -279,6 +286,7 @@ fun ItemDetailScreen( ) AdminAction.Refresh -> showEpisodeRefreshDialog = true AdminAction.Identify -> Unit + AdminAction.Delete -> showEpisodeDeleteDialog = true } }, ) @@ -289,6 +297,17 @@ fun ItemDetailScreen( onDismiss = { showEpisodeRefreshDialog = false }, ) } + + if (showEpisodeDeleteDialog) { + DeleteConfirmationDialog( + targetId = episode.id, + targetName = episode.name, + isMainItem = false, + viewModel = viewModel, + navController = navController, + onDismiss = { showEpisodeDeleteDialog = false }, + ) + } } LaunchedEffect(selectedEpisode, pendingNavigationSeriesId) { @@ -511,6 +530,7 @@ private fun LandscapeItemDetailContent( val canDownload by viewModel.canDownload.collectAsStateWithLifecycle() val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle() var showRefreshDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } val density = LocalDensity.current val statusBarHeight = WindowInsets.statusBars.getTop(density) val displayCutoutLeft = WindowInsets.displayCutout.getLeft(density, LayoutDirection.Ltr) @@ -665,6 +685,7 @@ private fun LandscapeItemDetailContent( ) ) AdminAction.Refresh -> showRefreshDialog = true + AdminAction.Delete -> showDeleteDialog = true } }, modifier = Modifier.weight(2f), @@ -676,6 +697,17 @@ private fun LandscapeItemDetailContent( onDismiss = { showRefreshDialog = false }, ) } + + if (showDeleteDialog) { + DeleteConfirmationDialog( + targetId = item.id, + targetName = item.name, + isMainItem = true, + viewModel = viewModel, + navController = navController, + onDismiss = { showDeleteDialog = false }, + ) + } } VideoQualitySelection( @@ -751,6 +783,7 @@ private fun PortraitItemDetailContent( val canDownload by viewModel.canDownload.collectAsStateWithLifecycle() val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle() var showRefreshDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } val playerOffset = LocalPlayerOffset.current LazyColumn( @@ -839,6 +872,7 @@ private fun PortraitItemDetailContent( Destination.createEditImagesRoute(item.id.toString()) ) AdminAction.Refresh -> showRefreshDialog = true + AdminAction.Delete -> showDeleteDialog = true } }, ) @@ -850,6 +884,17 @@ private fun PortraitItemDetailContent( ) } + if (showDeleteDialog) { + DeleteConfirmationDialog( + targetId = item.id, + targetName = item.name, + isMainItem = true, + viewModel = viewModel, + navController = navController, + onDismiss = { showDeleteDialog = false }, + ) + } + VideoQualitySelection( mediaSourceOptions = mediaSourceOptions, selectedSource = selectedMediaSource, @@ -1155,3 +1200,60 @@ private fun shufflePlay(item: AfinityItem, nextEpisode: AfinityEpisode?, context ) } } + +@Composable +private fun DeleteConfirmationDialog( + targetId: UUID, + targetName: String, + isMainItem: Boolean, + viewModel: ItemDetailViewModel, + navController: NavController, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.admin_delete_dialog_title)) }, + text = { Text(stringResource(R.string.admin_delete_dialog_message, targetName)) }, + confirmButton = { + Button( + onClick = { + onDismiss() + viewModel.deleteItem( + targetItemId = targetId, + onSuccess = { + Toast.makeText( + context, + R.string.admin_delete_success, + Toast.LENGTH_SHORT, + ).show() + if (isMainItem) { + navController.popBackStack() + } else { + viewModel.clearSelectedEpisode() + } + }, + onError = { error -> + Toast.makeText( + context, + context.getString(R.string.admin_delete_error, error), + Toast.LENGTH_LONG, + ).show() + }, + ) + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.admin_action_delete)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} 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 2cf12cd5..e43d6de0 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 @@ -12,6 +12,7 @@ import androidx.paging.map import com.makd.afinity.R import com.makd.afinity.data.database.entities.ItemMetadataCacheEntity import com.makd.afinity.data.manager.AdminChangeBroadcaster +import com.makd.afinity.data.repository.admin.AdminRepository import com.makd.afinity.data.manager.MediaChangeManager import com.makd.afinity.data.manager.MediaChangeSource import com.makd.afinity.data.manager.OfflineModeManager @@ -98,6 +99,7 @@ constructor( private val authRepository: AuthRepository, private val playbackStateManager: PlaybackStateManager, private val adminChangeBroadcaster: AdminChangeBroadcaster, + private val adminRepository: AdminRepository, private val mediaChangeManager: MediaChangeManager, private val serverRepository: ServerRepository, private val securePreferencesRepository: SecurePreferencesRepository, @@ -1574,6 +1576,23 @@ constructor( return episodes.firstOrNull { it.playbackPositionTicks > 0 && !it.played } ?: episodes.firstOrNull { !it.played } } + + fun deleteItem( + targetItemId: UUID, + onSuccess: () -> Unit, + onError: (String) -> Unit, + ) { + viewModelScope.launch { + val result = adminRepository.deleteItem(targetItemId.toString()) + if (result.isSuccess) { + adminChangeBroadcaster.notifyItemChanged(targetItemId.toString()) + onSuccess() + } else { + val errorMsg = result.exceptionOrNull()?.localizedMessage ?: "Unknown error" + onError(errorMsg) + } + } + } } data class ItemDetailUiState( diff --git a/app/src/main/java/com/makd/afinity/ui/item/components/EpisodeDetailOverlay.kt b/app/src/main/java/com/makd/afinity/ui/item/components/EpisodeDetailOverlay.kt index 599e6e4c..6ae8bd71 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/components/EpisodeDetailOverlay.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/components/EpisodeDetailOverlay.kt @@ -557,6 +557,26 @@ fun EpisodeDetailOverlay( onAdminAction(AdminAction.Refresh) }, ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.admin_action_delete), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + }, + onClick = { + menuExpanded = false + onAdminAction(AdminAction.Delete) + }, + ) } } } diff --git a/app/src/main/java/com/makd/afinity/ui/item/components/shared/ActionButtonsRow.kt b/app/src/main/java/com/makd/afinity/ui/item/components/shared/ActionButtonsRow.kt index 412d4dde..15eb037f 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/components/shared/ActionButtonsRow.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/components/shared/ActionButtonsRow.kt @@ -181,6 +181,26 @@ fun ActionButtonsRow( onAdminAction(AdminAction.Refresh) }, ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.admin_action_delete), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + }, + onClick = { + menuExpanded = false + onAdminAction(AdminAction.Delete) + }, + ) } } } diff --git a/app/src/main/java/com/makd/afinity/ui/item/components/shared/AdminAction.kt b/app/src/main/java/com/makd/afinity/ui/item/components/shared/AdminAction.kt index d4e266eb..4252ecbd 100644 --- a/app/src/main/java/com/makd/afinity/ui/item/components/shared/AdminAction.kt +++ b/app/src/main/java/com/makd/afinity/ui/item/components/shared/AdminAction.kt @@ -5,4 +5,5 @@ sealed interface AdminAction { data object Identify : AdminAction data object EditImages : AdminAction data object Refresh : AdminAction + data object Delete : AdminAction } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee52eb26..9a0627a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1331,6 +1331,11 @@ Identify Edit Images Refresh Metadata + Delete + Delete Item + Are you sure you want to delete "%1$s"? This action cannot be undone and will delete the media file(s) from your server. + Item deleted successfully + Failed to delete item: %1$s Refresh Metadata