diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminQuarantineRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminQuarantineRepository.kt new file mode 100644 index 00000000..f038163c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminQuarantineRepository.kt @@ -0,0 +1,57 @@ +package com.fabledsword.minstrel.admin.data + +import com.fabledsword.minstrel.api.endpoints.AdminQuarantineApi +import com.fabledsword.minstrel.models.AdminQuarantineItemRef +import com.fabledsword.minstrel.models.AdminQuarantineReportRef +import com.fabledsword.minstrel.models.wire.AdminQuarantineItemWire +import com.fabledsword.minstrel.models.wire.AdminQuarantineReportWire +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Read-through accessor for the admin quarantine queue. Mirrors the + * Flutter `AdminQuarantineController`. No Room cache — same rationale + * as AdminRequests. Three resolution actions all delete the row from + * the queue once the server accepts them. + */ +@Singleton +class AdminQuarantineRepository @Inject constructor( + retrofit: Retrofit, +) { + private val api: AdminQuarantineApi = retrofit.create() + + suspend fun list(): List = api.list().map { it.toDomain() } + + suspend fun resolve(trackId: String) = api.resolve(trackId) + + suspend fun deleteFile(trackId: String) = api.deleteFile(trackId) + + suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId) +} + +// ── Mappers ── + +private fun AdminQuarantineReportWire.toDomain(): AdminQuarantineReportRef = + AdminQuarantineReportRef( + userId = userId, + username = username, + reason = reason, + notes = notes, + createdAt = createdAt, + ) + +private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef = + AdminQuarantineItemRef( + trackId = trackId, + trackTitle = trackTitle, + artistName = artistName, + albumTitle = albumTitle, + albumId = albumId, + lidarrAlbumMbid = lidarrAlbumMbid, + reportCount = reportCount, + latestAt = latestAt, + reasonCounts = reasonCounts, + reports = reports.map { it.toDomain() }, + ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt new file mode 100644 index 00000000..93691411 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt @@ -0,0 +1,190 @@ +package com.fabledsword.minstrel.admin.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ArrowLeft +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.models.AdminQuarantineItemRef +import com.fabledsword.minstrel.nav.AdminQuarantine +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.MainAppBarActions + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdminQuarantineScreen( + navController: NavHostController, + viewModel: AdminQuarantineViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Admin · Quarantine") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Lucide.ArrowLeft, contentDescription = "Back") + } + }, + actions = { + MainAppBarActions( + navController = navController, + currentRouteName = AdminQuarantine::class.qualifiedName, + ) + }, + ) + }, + ) { inner -> + Box(modifier = Modifier.fillMaxSize().padding(inner)) { + when (val s = state) { + AdminQuarantineUiState.Loading -> LoadingCentered() + AdminQuarantineUiState.Empty -> EmptyState( + title = "Queue is empty", + body = "When users flag tracks as bad rips, wrong tags, or " + + "duplicates, their reports get aggregated and surfaced here.", + ) + is AdminQuarantineUiState.Error -> EmptyState( + title = "Couldn't load queue", + body = s.message, + ) + is AdminQuarantineUiState.Success -> QueueList( + rows = s.rows, + onResolve = viewModel::resolve, + onDeleteFile = viewModel::deleteFile, + onDeleteViaLidarr = viewModel::deleteViaLidarr, + ) + } + } + } +} + +@Composable +private fun QueueList( + rows: List, + onResolve: (String) -> Unit, + onDeleteFile: (String) -> Unit, + onDeleteViaLidarr: (String) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items = rows, key = { it.trackId }) { row -> + QuarantineRow( + row = row, + onResolve = { onResolve(row.trackId) }, + onDeleteFile = { onDeleteFile(row.trackId) }, + onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) }, + ) + HorizontalDivider() + } + } +} + +@Composable +private fun QuarantineRow( + row: AdminQuarantineItemRef, + onResolve: () -> Unit, + onDeleteFile: () -> Unit, + onDeleteViaLidarr: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = row.trackTitle, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${row.artistName} · ${row.albumTitle}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AssistChip( + onClick = {}, + enabled = false, + label = { + Text( + text = "${row.reportCount} reports", + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + if (row.topReasonSummary.isNotEmpty()) { + AssistChip( + onClick = {}, + enabled = false, + label = { + Text( + text = row.topReasonSummary, + style = MaterialTheme.typography.labelSmall, + ) + }, + ) + } + } + ActionButtons( + onResolve = onResolve, + onDeleteFile = onDeleteFile, + onDeleteViaLidarr = onDeleteViaLidarr, + ) + } +} + +@Composable +private fun ActionButtons( + onResolve: () -> Unit, + onDeleteFile: () -> Unit, + onDeleteViaLidarr: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onResolve) { Text("Resolve") } + OutlinedButton(onClick = onDeleteFile) { Text("Delete file") } + Button(onClick = onDeleteViaLidarr) { Text("Delete + Lidarr") } + } +} + +@Composable +private fun LoadingCentered() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt new file mode 100644 index 00000000..1987776e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineViewModel.kt @@ -0,0 +1,77 @@ +package com.fabledsword.minstrel.admin.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository +import com.fabledsword.minstrel.models.AdminQuarantineItemRef +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface AdminQuarantineUiState { + data object Loading : AdminQuarantineUiState + data object Empty : AdminQuarantineUiState + data class Success(val rows: List) : AdminQuarantineUiState + data class Error(val message: String) : AdminQuarantineUiState +} + +@HiltViewModel +class AdminQuarantineViewModel @Inject constructor( + private val repository: AdminQuarantineRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(AdminQuarantineUiState.Loading) + val uiState: StateFlow = internal.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + internal.value = AdminQuarantineUiState.Loading + try { + val rows = repository.list() + internal.value = if (rows.isEmpty()) { + AdminQuarantineUiState.Empty + } else { + AdminQuarantineUiState.Success(rows) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = AdminQuarantineUiState.Error( + e.message ?: "Admin quarantine load failed", + ) + } + } + } + + fun resolve(trackId: String) = act(trackId) { repository.resolve(it) } + fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) } + fun deleteViaLidarr(trackId: String) = act(trackId) { repository.deleteViaLidarr(it) } + + private fun act(trackId: String, action: suspend (String) -> Unit) { + val before = internal.value + if (before is AdminQuarantineUiState.Success) { + val filtered = before.rows.filterNot { it.trackId == trackId } + internal.value = if (filtered.isEmpty()) { + AdminQuarantineUiState.Empty + } else { + AdminQuarantineUiState.Success(filtered) + } + } + viewModelScope.launch { + try { + action(trackId) + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + refresh() + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt new file mode 100644 index 00000000..4a483849 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt @@ -0,0 +1,29 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.AdminQuarantineItemWire +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * Retrofit interface for `/api/admin/quarantine`. Mirrors + * `flutter_client/lib/api/endpoints/admin_quarantine.dart`. + * + * Three resolution endpoints: + * - `resolve` → admin reviewed, no action taken (clears flags). + * - `delete-file` → drop the local file; Lidarr keeps the track. + * - `delete-via-lidarr` → ask Lidarr to remove the track upstream too. + */ +interface AdminQuarantineApi { + @GET("api/admin/quarantine") + suspend fun list(): List + + @POST("api/admin/quarantine/{trackId}/resolve") + suspend fun resolve(@Path("trackId") trackId: String) + + @POST("api/admin/quarantine/{trackId}/delete-file") + suspend fun deleteFile(@Path("trackId") trackId: String) + + @POST("api/admin/quarantine/{trackId}/delete-via-lidarr") + suspend fun deleteViaLidarr(@Path("trackId") trackId: String) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AdminQuarantineItem.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminQuarantineItem.kt new file mode 100644 index 00000000..03b1b09c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminQuarantineItem.kt @@ -0,0 +1,41 @@ +package com.fabledsword.minstrel.models + +/** + * One user's quarantine report under an aggregated row. + */ +data class AdminQuarantineReportRef( + val userId: String, + val username: String, + val reason: String, + val notes: String? = null, + val createdAt: String = "", +) + +/** + * Aggregated quarantine row in the admin queue — all reports against a + * single track collapsed into per-reason counts plus the underlying + * per-user reports. Mirrors Flutter's `AdminQuarantineItem`. + * + * `topReasonSummary` returns the most-cited reason, with a "(+N more)" + * suffix when other distinct reasons exist. + */ +data class AdminQuarantineItemRef( + val trackId: String, + val trackTitle: String, + val artistName: String, + val albumTitle: String, + val albumId: String, + val lidarrAlbumMbid: String? = null, + val reportCount: Int = 0, + val latestAt: String = "", + val reasonCounts: Map = emptyMap(), + val reports: List = emptyList(), +) { + val topReasonSummary: String + get() { + if (reasonCounts.isEmpty()) return "" + val sorted = reasonCounts.entries.sortedByDescending { it.value } + val top = sorted.first().key + return if (sorted.size > 1) "$top (+${sorted.size - 1} more)" else top + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminQuarantineWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminQuarantineWire.kt new file mode 100644 index 00000000..f41d4bd8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminQuarantineWire.kt @@ -0,0 +1,37 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One user's individual report under an aggregated quarantine row. + * Mirrors Flutter's `AdminQuarantineReport` / + * `internal/api/admin_quarantine.go` reportView. + */ +@Serializable +data class AdminQuarantineReportWire( + @SerialName("user_id") val userId: String = "", + val username: String = "", + val reason: String = "", + val notes: String? = null, + @SerialName("created_at") val createdAt: String = "", +) + +/** + * One row of `GET /api/admin/quarantine` — all reports against a + * single track collapsed into per-reason counts plus the underlying + * per-user reports. Mirrors `adminQueueRowView`. + */ +@Serializable +data class AdminQuarantineItemWire( + @SerialName("track_id") val trackId: String = "", + @SerialName("track_title") val trackTitle: String = "", + @SerialName("artist_name") val artistName: String = "", + @SerialName("album_title") val albumTitle: String = "", + @SerialName("album_id") val albumId: String = "", + @SerialName("lidarr_album_mbid") val lidarrAlbumMbid: String? = null, + @SerialName("report_count") val reportCount: Int = 0, + @SerialName("latest_at") val latestAt: String = "", + @SerialName("reason_counts") val reasonCounts: Map = emptyMap(), + val reports: List = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index bc64eeef..ce848b78 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -10,6 +10,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.toRoute +import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen import com.fabledsword.minstrel.discover.ui.DiscoverScreen import com.fabledsword.minstrel.home.ui.HomeScreen @@ -120,7 +121,7 @@ private fun NavGraphBuilder.inShellDetail( } composable { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Admin · Quarantine", "Lands with admin slice.") + AdminQuarantineScreen(navController = navController) } } composable {