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.
This commit is contained in:
+47
-1
@@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
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.CancelOutcome
|
||||||
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -20,6 +22,10 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
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
|
@HiltViewModel
|
||||||
class RequestsViewModel @Inject constructor(
|
class RequestsViewModel @Inject constructor(
|
||||||
private val repository: RequestsRepository,
|
private val repository: RequestsRepository,
|
||||||
@@ -31,11 +37,14 @@ class RequestsViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// SSE push: reconciler completions arrive here; reload silently so the
|
||||||
|
// row updates in place rather than flashing the loading spinner.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
eventsStream.events
|
eventsStream.events
|
||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.collect { silentReload() }
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch { pollWhileInFlight() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
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
|
* Optimistically removes [id] from the success list so the row
|
||||||
* disappears immediately; on server-side success the row is
|
* disappears immediately; on server-side success the row is
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
||||||
import { createRequest } from '$lib/api/requests';
|
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 DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte';
|
||||||
import DiscoverTabs from '$lib/components/DiscoverTabs.svelte';
|
import DiscoverTabs from '$lib/components/DiscoverTabs.svelte';
|
||||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||||
@@ -12,6 +14,8 @@
|
|||||||
LidarrSearchResult
|
LidarrSearchResult
|
||||||
} from '$lib/api/types';
|
} from '$lib/api/types';
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Local input is the source of truth for the discover query — Shell's global
|
// Local input is the source of truth for the discover query — Shell's global
|
||||||
// SearchInput targets /search, not /discover. We debounce by 250ms before
|
// SearchInput targets /search, not /discover. We debounce by 250ms before
|
||||||
// pushing into `debouncedQ`, which is what the query factory observes.
|
// pushing into `debouncedQ`, which is what the query factory observes.
|
||||||
@@ -87,6 +91,8 @@
|
|||||||
const next = new Set(optimisticRequested);
|
const next = new Set(optimisticRequested);
|
||||||
next.add(r.mbid);
|
next.add(r.mbid);
|
||||||
optimisticRequested = next;
|
optimisticRequested = next;
|
||||||
|
// Surface the new request on /requests right away (+ start its poll) (#369).
|
||||||
|
queryClient.invalidateQueries({ queryKey: qk.myRequests() });
|
||||||
} catch {
|
} catch {
|
||||||
// Swallow for v1: error toasts land in a later UX pass. The card stays
|
// Swallow for v1: error toasts land in a later UX pass. The card stays
|
||||||
// in 'requestable' so the user can retry.
|
// in 'requestable' so the user can retry.
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ vi.mock('$lib/api/requests', () => ({
|
|||||||
|
|
||||||
vi.mock('$lib/api/client', () => apiClientMock());
|
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<typeof import('@tanstack/svelte-query')>();
|
||||||
|
return { ...actual, useQueryClient: () => queryClientMock };
|
||||||
|
});
|
||||||
|
|
||||||
import DiscoverPage from './+page.svelte';
|
import DiscoverPage from './+page.svelte';
|
||||||
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
||||||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
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<LidarrSearchResult[]>({ 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 () => {
|
test('track-kind Request click opens confirm modal; Confirm calls createRequest', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const r = result({
|
const r = result({
|
||||||
|
|||||||
Reference in New Issue
Block a user