diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminTagSourcesRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminTagSourcesRepository.kt new file mode 100644 index 00000000..9a6e9416 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminTagSourcesRepository.kt @@ -0,0 +1,48 @@ +package com.fabledsword.minstrel.admin.data + +import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi +import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody +import com.fabledsword.minstrel.models.AdminTagSourceRef +import com.fabledsword.minstrel.models.TagSourceTestResult +import com.fabledsword.minstrel.models.wire.AdminTagSourceWire +import com.fabledsword.minstrel.models.wire.TestTagSourceWire +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Read-through accessor for the tag-enrichment provider settings (#1521). + * No Room caching — admin settings are infrequent point-and-shoot edits; + * mutations fire direct REST and the ViewModel reconciles on failure. + * Exceptions propagate to the ViewModel (which maps them via ErrorCopy). + */ +@Singleton +class AdminTagSourcesRepository @Inject constructor( + retrofit: Retrofit, +) { + private val api: AdminTagSourcesApi = retrofit.create() + + suspend fun list(): List = api.list().providers.map { it.toDomain() } + + suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef = + api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain() + + suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef = + api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain() + + suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult() +} + +private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef( + id = id, + displayName = displayName, + requiresApiKey = requiresApiKey, + supports = supports, + enabled = enabled, + apiKeySet = apiKeySet, + testable = testable, +) + +private fun TestTagSourceWire.toResult(): TagSourceTestResult = + TagSourceTestResult(ok = ok, durationMs = durationMs, error = error) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt index 6f0b0a27..0a60d78b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt @@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController import com.composables.icons.lucide.Inbox import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Music import com.composables.icons.lucide.TriangleAlert import com.composables.icons.lucide.Users import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository import com.fabledsword.minstrel.admin.data.AdminRequestsRepository +import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository import com.fabledsword.minstrel.admin.data.AdminUsersRepository import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.nav.Admin import com.fabledsword.minstrel.nav.AdminQuarantine import com.fabledsword.minstrel.nav.AdminRequests +import com.fabledsword.minstrel.nav.AdminTagSources import com.fabledsword.minstrel.nav.AdminUsers import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.LoadingCentered @@ -54,7 +57,7 @@ import javax.inject.Inject // ─── State ─────────────────────────────────────────────────────────── -data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int) +data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int) sealed interface AdminLandingUiState { data object Loading : AdminLandingUiState @@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor( private val requestsRepo: AdminRequestsRepository, private val quarantineRepo: AdminQuarantineRepository, private val usersRepo: AdminUsersRepository, + private val tagSourcesRepo: AdminTagSourcesRepository, ) : ViewModel() { private val internal = MutableStateFlow(AdminLandingUiState.Loading) @@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor( val req = async { requestsRepo.list().size } val qua = async { quarantineRepo.list().size } val usr = async { usersRepo.list().size } - AdminCounts(req.await(), qua.await(), usr.await()) + val tag = async { tagSourcesRepo.list().count { it.enabled } } + AdminCounts(req.await(), qua.await(), usr.await(), tag.await()) } internal.value = AdminLandingUiState.Success(counts) } catch ( @@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) { onClick = { navController.navigate(AdminUsers) }, ) } + item { + SectionCard( + icon = Lucide.Music, + title = "Tag sources", + subtitle = "Metadata enrichment providers", + count = counts.tagSources, + onClick = { navController.navigate(AdminTagSources) }, + ) + } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesScreen.kt new file mode 100644 index 00000000..cc6f0cf8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesScreen.kt @@ -0,0 +1,220 @@ +package com.fabledsword.minstrel.admin.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.fabledsword.minstrel.models.AdminTagSourceRef +import com.fabledsword.minstrel.models.TagSourceTestResult +import com.fabledsword.minstrel.nav.AdminTagSources +import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.ErrorRetry +import com.fabledsword.minstrel.shared.widgets.LoadingCentered +import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdminTagSourcesScreen( + navController: NavHostController, + viewModel: AdminTagSourcesViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + MinstrelTopAppBar( + title = "Admin · Tag sources", + navController = navController, + currentRouteName = AdminTagSources::class.qualifiedName, + onBack = { navController.popBackStack() }, + ) + }, + ) { inner -> + PullToRefreshScaffold( + onRefresh = { viewModel.refresh().join() }, + modifier = Modifier.fillMaxSize().padding(inner), + ) { + when (val s = state) { + AdminTagSourcesUiState.Loading -> LoadingCentered() + AdminTagSourcesUiState.Empty -> EmptyState( + title = "No tag sources", + body = "Tag-enrichment providers register on the server; none are available.", + ) + is AdminTagSourcesUiState.Error -> ErrorRetry( + title = "Couldn't load tag sources", + message = s.message, + onRetry = { viewModel.refresh() }, + ) + is AdminTagSourcesUiState.Success -> TagSourceList( + providers = s.providers, + testResults = s.testResults, + onToggle = viewModel::setEnabled, + onSaveKey = viewModel::saveApiKey, + onTest = viewModel::test, + ) + } + } + } +} + +@Composable +private fun TagSourceList( + providers: List, + testResults: Map, + onToggle: (String, Boolean) -> Unit, + onSaveKey: (String, String) -> Unit, + onTest: (String) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items = providers, key = { it.id }) { provider -> + TagSourceCard( + provider = provider, + testResult = testResults[provider.id], + onToggle = onToggle, + onSaveKey = onSaveKey, + onTest = onTest, + ) + } + } +} + +@Composable +private fun TagSourceCard( + provider: AdminTagSourceRef, + testResult: TagSourceTestResult?, + onToggle: (String, Boolean) -> Unit, + onSaveKey: (String, String) -> Unit, + onTest: (String) -> Unit, +) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(provider.displayName, style = MaterialTheme.typography.titleMedium) + Text( + text = provider.supports.joinToString(", ") { it.replace('_', ' ') }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = provider.enabled, + onCheckedChange = { onToggle(provider.id, it) }, + ) + } + if (provider.requiresApiKey) { + ApiKeyRow(provider = provider, onSaveKey = onSaveKey) + } + if (provider.testable) { + TestRow(provider = provider, testResult = testResult, onTest = onTest) + } + } + } +} + +@Composable +private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) { + var key by remember(provider.id) { mutableStateOf("") } + OutlinedTextField( + value = key, + onValueChange = { key = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("API key") }, + placeholder = { + Text( + if (provider.apiKeySet) { + "••• saved — leave blank to keep" + } else { + "Paste your API key to enable this source" + }, + ) + }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = { + onSaveKey(provider.id, key) + key = "" + }, + enabled = key.isNotBlank(), + ) { Text("Save key") } + if (provider.apiKeySet) { + Text("✓ Set", style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun TestRow( + provider: AdminTagSourceRef, + testResult: TagSourceTestResult?, + onTest: (String) -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") } + testResult?.let { result -> + if (result.ok) { + Text( + text = "OK (${result.durationMs}ms)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + text = "Failed — ${result.error}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesViewModel.kt new file mode 100644 index 00000000..53cbdfaa --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesViewModel.kt @@ -0,0 +1,116 @@ +package com.fabledsword.minstrel.admin.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository +import com.fabledsword.minstrel.api.ErrorCopy +import com.fabledsword.minstrel.connectivity.NetworkStatusController +import com.fabledsword.minstrel.connectivity.recoveries +import com.fabledsword.minstrel.models.AdminTagSourceRef +import com.fabledsword.minstrel.models.TagSourceTestResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +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 AdminTagSourcesUiState { + data object Loading : AdminTagSourcesUiState + data object Empty : AdminTagSourcesUiState + data class Success( + val providers: List, + val testResults: Map, + ) : AdminTagSourcesUiState + data class Error(val message: String) : AdminTagSourcesUiState +} + +@HiltViewModel +class AdminTagSourcesViewModel @Inject constructor( + private val repository: AdminTagSourcesRepository, + networkStatus: NetworkStatusController, +) : ViewModel() { + + private val internal = MutableStateFlow(AdminTagSourcesUiState.Loading) + val uiState: StateFlow = internal.asStateFlow() + + init { + refresh() + // Screen-level auto-recovery: reload a failed list when server + // health returns instead of waiting for a manual pull (issue #1245). + viewModelScope.launch { + networkStatus.recoveries().collect { + if (internal.value is AdminTagSourcesUiState.Error) refresh() + } + } + } + + fun refresh(): Job = viewModelScope.launch { + internal.value = AdminTagSourcesUiState.Loading + try { + val providers = repository.list() + internal.value = if (providers.isEmpty()) { + AdminTagSourcesUiState.Empty + } else { + AdminTagSourcesUiState.Success(providers, emptyMap()) + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e)) + } + } + + fun setEnabled(id: String, enabled: Boolean) { + val before = internal.value as? AdminTagSourcesUiState.Success ?: return + // Optimistic toggle; reconcile via refresh() if the server rejects. + internal.value = before.copy( + providers = before.providers.map { + if (it.id == id) it.copy(enabled = enabled) else it + }, + ) + viewModelScope.launch { + try { + repository.setEnabled(id, enabled) + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + refresh() + } + } + } + + fun saveApiKey(id: String, apiKey: String) { + viewModelScope.launch { + try { + replaceProvider(repository.setApiKey(id, apiKey)) + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + refresh() + } + } + } + + fun test(id: String) { + viewModelScope.launch { + val result = try { + repository.test(id) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e)) + } + val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch + internal.value = current.copy(testResults = current.testResults + (id to result)) + } + } + + private fun replaceProvider(updated: AdminTagSourceRef) { + val current = internal.value as? AdminTagSourcesUiState.Success ?: return + internal.value = current.copy( + providers = current.providers.map { if (it.id == updated.id) updated else it }, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminTagSourcesApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminTagSourcesApi.kt new file mode 100644 index 00000000..e02ebeec --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminTagSourcesApi.kt @@ -0,0 +1,41 @@ +package com.fabledsword.minstrel.api.endpoints + +import com.fabledsword.minstrel.models.wire.AdminTagSourceWire +import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire +import com.fabledsword.minstrel.models.wire.TestTagSourceWire +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin + * surface for the tag-enrichment provider settings (#1490). Mirrors the + * web integrations "Tag enrichment sources" card. Same shape as the + * cover-sources admin surface; kept independent so a new tag source is + * added without touching art settings. + */ +interface AdminTagSourcesApi { + @GET("api/admin/tag-sources") + suspend fun list(): AdminTagSourcesListWire + + @PATCH("api/admin/tag-sources/{id}") + suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire + + @POST("api/admin/tag-sources/{id}/test") + suspend fun test(@Path("id") id: String): TestTagSourceWire +} + +/** + * PATCH body. Both fields are nullable + default-null so kotlinx omits the + * untouched one (the server reads a missing/null field as "leave + * unchanged"): send only `enabled` to toggle, only `apiKey` to set a key. + */ +@Serializable +data class UpdateTagSourceBody( + val enabled: Boolean? = null, + @SerialName("api_key") val apiKey: String? = null, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AdminTagSourceRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminTagSourceRef.kt new file mode 100644 index 00000000..f892c7aa --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminTagSourceRef.kt @@ -0,0 +1,19 @@ +package com.fabledsword.minstrel.models + +/** Domain model for one tag-enrichment provider in the admin surface (#1521). */ +data class AdminTagSourceRef( + val id: String, + val displayName: String, + val requiresApiKey: Boolean, + val supports: List, + val enabled: Boolean, + val apiKeySet: Boolean, + val testable: Boolean, +) + +/** Result of a provider "test connection" call. */ +data class TagSourceTestResult( + val ok: Boolean, + val durationMs: Long = 0, + val error: String = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminTagSourceWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminTagSourceWire.kt new file mode 100644 index 00000000..375f3a3a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminTagSourceWire.kt @@ -0,0 +1,35 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521). + * Every field defaults defensively so a missing JSON key never crashes + * deserialization. `versionBumped` is present only on the PATCH response. + */ +@Serializable +data class AdminTagSourceWire( + val id: String = "", + @SerialName("display_name") val displayName: String = "", + @SerialName("requires_api_key") val requiresApiKey: Boolean = false, + val supports: List = emptyList(), + val enabled: Boolean = false, + @SerialName("api_key_set") val apiKeySet: Boolean = false, + @SerialName("display_order") val displayOrder: Int = 0, + val testable: Boolean = false, + @SerialName("version_bumped") val versionBumped: Boolean = false, +) + +@Serializable +data class AdminTagSourcesListWire( + val providers: List = emptyList(), + @SerialName("sources_version") val sourcesVersion: Int = 0, +) + +@Serializable +data class TestTagSourceWire( + val ok: Boolean = false, + @SerialName("duration_ms") val durationMs: Long = 0, + val error: String = "", +) 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 987844b6..6f03705c 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 @@ -16,6 +16,7 @@ import androidx.navigation.compose.composable import com.fabledsword.minstrel.admin.ui.AdminLandingScreen import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen +import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen import com.fabledsword.minstrel.admin.ui.AdminUsersScreen import com.fabledsword.minstrel.auth.ui.LoginScreen import com.fabledsword.minstrel.auth.ui.ServerUrlScreen @@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail( } } } + composable { + WithAnimatedScope { + ShellScaffold(onExpandPlayer = expandPlayer) { + AdminTagSourcesScreen(navController = navController) + } + } + } } private fun NavGraphBuilder.outsideShell(navController: NavHostController) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt index 24509fe6..6e470f85 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt @@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable @Serializable data object AdminRequests @Serializable data object AdminQuarantine @Serializable data object AdminUsers +@Serializable data object AdminTagSources // ── Outside-shell full-screen routes ──────────────────────────────────