From 8f29cc7414dbd698964ef604ae881cb36160cfe0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 6 Jun 2026 23:18:44 -0400 Subject: [PATCH] feat: poll in-flight requests on native + refresh after submit (#369) Native RequestsViewModel gains poll-while-approved (silent reloads, paused when nothing in-flight) for parity with the web auto-poll, and the SSE collector now reloads silently instead of flashing the loading spinner. Web discover submit invalidates qk.myRequests() so a new request appears on /requests immediately. --- .../minstrel/requests/ui/RequestsViewModel.kt | 48 ++++++++++++++++++- web/src/routes/discover/+page.svelte | 6 +++ web/src/routes/discover/discover.test.ts | 30 ++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt index 7759a78e..9f2ee4b9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt @@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope import com.fabledsword.minstrel.api.ErrorCopy import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.models.RequestRef +import com.fabledsword.minstrel.models.RequestStatus import com.fabledsword.minstrel.requests.data.CancelOutcome import com.fabledsword.minstrel.requests.data.RequestsRepository import com.fabledsword.minstrel.shared.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -20,6 +22,10 @@ import javax.inject.Inject private val RELEVANT_EVENT_KINDS = setOf("request.status_changed") +// Cadence for in-flight request polling — matches the web client (#369): +// fast enough to feel live, slow enough not to hammer the server. +private const val POLL_INTERVAL_MS = 12_000L + @HiltViewModel class RequestsViewModel @Inject constructor( private val repository: RequestsRepository, @@ -31,11 +37,14 @@ class RequestsViewModel @Inject constructor( init { refresh() + // SSE push: reconciler completions arrive here; reload silently so the + // row updates in place rather than flashing the loading spinner. viewModelScope.launch { eventsStream.events .filter { it.kind in RELEVANT_EVENT_KINDS } - .collect { refresh() } + .collect { silentReload() } } + viewModelScope.launch { pollWhileInFlight() } } fun refresh(): Job = viewModelScope.launch { @@ -54,6 +63,43 @@ class RequestsViewModel @Inject constructor( } } + /** + * Auto-poll while any request is mid-ingest (status APPROVED), matching the + * web client (#369). Reloads silently every [POLL_INTERVAL_MS] and pauses + * when nothing is in-flight — complements the SSE push so progress stays + * live even if the event stream drops. + */ + private suspend fun pollWhileInFlight() { + while (true) { + if (hasInFlightRequest()) { + silentReload() + } + delay(POLL_INTERVAL_MS) + } + } + + private fun hasInFlightRequest(): Boolean { + val state = internal.value + return state is UiState.Success && + state.data.any { it.status == RequestStatus.APPROVED } + } + + /** + * Reload the list in place without flipping to Loading — used by the poll + * loop and the SSE collector so visible rows update without a spinner + * flash. A transient failure keeps the current view; the next reload wins. + */ + private suspend fun silentReload() { + try { + val rows = repository.listMine() + internal.value = if (rows.isEmpty()) UiState.Empty else UiState.Success(rows) + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Keep the current view; the next poll / SSE / pull reconciles. + } + } + /** * Optimistically removes [id] from the success list so the row * disappears immediately; on server-side success the row is diff --git a/web/src/routes/discover/+page.svelte b/web/src/routes/discover/+page.svelte index ad28820a..6e16bab0 100644 --- a/web/src/routes/discover/+page.svelte +++ b/web/src/routes/discover/+page.svelte @@ -2,6 +2,8 @@ import { pageTitle } from '$lib/branding'; import { createLidarrSearchQuery } from '$lib/api/lidarr'; import { createRequest } from '$lib/api/requests'; + import { qk } from '$lib/api/queries'; + import { useQueryClient } from '@tanstack/svelte-query'; import DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte'; import DiscoverTabs from '$lib/components/DiscoverTabs.svelte'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; @@ -12,6 +14,8 @@ LidarrSearchResult } from '$lib/api/types'; + const queryClient = useQueryClient(); + // Local input is the source of truth for the discover query — Shell's global // SearchInput targets /search, not /discover. We debounce by 250ms before // pushing into `debouncedQ`, which is what the query factory observes. @@ -87,6 +91,8 @@ const next = new Set(optimisticRequested); next.add(r.mbid); optimisticRequested = next; + // Surface the new request on /requests right away (+ start its poll) (#369). + queryClient.invalidateQueries({ queryKey: qk.myRequests() }); } catch { // Swallow for v1: error toasts land in a later UX pass. The card stays // in 'requestable' so the user can retry. diff --git a/web/src/routes/discover/discover.test.ts b/web/src/routes/discover/discover.test.ts index b2d1c14f..c8cf2351 100644 --- a/web/src/routes/discover/discover.test.ts +++ b/web/src/routes/discover/discover.test.ts @@ -31,6 +31,15 @@ vi.mock('$lib/api/requests', () => ({ vi.mock('$lib/api/client', () => apiClientMock()); +// The page calls useQueryClient() to invalidate qk.myRequests() after a +// submit (#369); stub it so rendering doesn't require a real QueryClient and +// we can assert the invalidation fires. +const queryClientMock = vi.hoisted(() => ({ invalidateQueries: vi.fn() })); +vi.mock('@tanstack/svelte-query', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useQueryClient: () => queryClientMock }; +}); + import DiscoverPage from './+page.svelte'; import { createLidarrSearchQuery } from '$lib/api/lidarr'; import { createSuggestionsQuery } from '$lib/api/suggestions'; @@ -187,6 +196,27 @@ describe('Discover page', () => { }); }); + test('submitting a request invalidates myRequests so /requests updates (#369)', async () => { + vi.useFakeTimers(); + const r = result({ mbid: 'art-mbid', artist_mbid: 'art-mbid', name: 'Boards of Canada' }); + mockedCreateQuery.mockReturnValue(mockQuery({ data: [r] })); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'boards' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + const requestBtn = await screen.findByRole('button', { + name: /request boards of canada/i + }); + await fireEvent.click(requestBtn); + await waitFor(() => { + expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['myRequests'] + }); + }); + }); + test('track-kind Request click opens confirm modal; Confirm calls createRequest', async () => { vi.useFakeTimers(); const r = result({