diff --git a/web/src/mediaSources/pinFlowPoll.ts b/web/src/mediaSources/pinFlowPoll.ts index 7a89e9d1d..6522a75fd 100644 --- a/web/src/mediaSources/pinFlowPoll.ts +++ b/web/src/mediaSources/pinFlowPoll.ts @@ -57,7 +57,7 @@ const MESSAGES: Record = { success: 'Connected to Plex.', 'authorized-no-servers': 'Signed in to Plex, but no eligible servers were discovered.', timeout: 'Plex sign-in timed out — try again.', - 'budget-exhausted': 'Still working — this can take a while on a large first sync. Use Refresh to check again.' + 'budget-exhausted': 'Still working — this can take a while on a large first sync. Check again to see if it finished.' }; const TERMINAL: ReadonlySet = new Set([ diff --git a/web/src/screens/PlexSourceScreen.test.tsx b/web/src/screens/PlexSourceScreen.test.tsx index 1e85a0b94..960d65f5e 100644 --- a/web/src/screens/PlexSourceScreen.test.tsx +++ b/web/src/screens/PlexSourceScreen.test.tsx @@ -266,4 +266,46 @@ describe('PlexSourceScreen', () => { expect(screen.queryByText(/Your Plex account is authorized/i)).toBeNull(); expect(screen.queryByText(/no eligible servers were discovered/i)).toBeNull(); }); + + // #367: budget-exhausted told the user to "Use Refresh to check again", but the Servers card (the + // only place Refresh lives) doesn't render with zero servers, so the instruction pointed at nothing. + // The message now describes checking again generically, and a global "Check again" button re-checks + // GET /api/v1/media-sources/plex without resuming the (already spent) timed poll loop. + it('budget-exhausted with no servers offers a working "Check again" control instead of a dead Refresh reference', async () => { + const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as PlexState }; + const counter = { state: 0 }; + installFetch(stateRef, counter); + vi.spyOn(window, 'open').mockReturnValue({} as Window); + vi.useFakeTimers(); + + render(); + await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Sign in to Plex' })).toBeTruthy()); + + stateRef.current = { isAuthorized: true, isLocked: true, servers: [] }; + fireEvent.click(screen.getByRole('button', { name: 'Sign in to Plex' })); + await vi.waitFor(() => expect(screen.getByText(/discovering your Plex servers|Waiting/i)).toBeTruthy()); + + await vi.advanceTimersByTimeAsync(152_000); // consume the whole 150s budget + await vi.waitFor(() => expect(screen.getByText(/Still working/i)).toBeTruthy()); + + // The message no longer references a "Refresh" control that isn't rendered (no Servers card yet). + expect(screen.queryByText(/Use Refresh/i)).toBeNull(); + expect(screen.queryByRole('heading', { name: 'Servers' })).toBeNull(); + + // A real, working "Check again" control is offered instead. + const checkAgainButton = screen.getByRole('button', { name: 'Check again' }); + expect(checkAgainButton).toBeTruthy(); + + const callsBeforeCheck = counter.state; + + // The next sync completes and a server is discovered by the time the user checks again. + stateRef.current = { isAuthorized: true, isLocked: false, servers: [{ id: 3, name: 'Attic Server', address: 'http://plex:32400' }] }; + fireEvent.click(checkAgainButton); + + // Check again performs its own fetch (does not rely on the exhausted timed poll loop resuming). + await vi.waitFor(() => expect(counter.state).toBeGreaterThan(callsBeforeCheck)); + await vi.waitFor(() => expect(screen.getByRole('heading', { name: 'Servers' })).toBeTruthy()); + expect(screen.getByText('Attic Server')).toBeTruthy(); + expect(screen.getByText('Connected to Plex.')).toBeTruthy(); + }); }); diff --git a/web/src/screens/PlexSourceScreen.tsx b/web/src/screens/PlexSourceScreen.tsx index 81beab971..f97b1b4cd 100644 --- a/web/src/screens/PlexSourceScreen.tsx +++ b/web/src/screens/PlexSourceScreen.tsx @@ -28,13 +28,16 @@ type BootState = // — the poll runs against GET /api/v1/media-sources/plex every 2s and, crucially, keeps polling while // authorized-but-still-locked ("finalizing / discovering servers"); the terminal success signal is // the lock RELEASING (see pinFlowPoll.ts). Server rows offer Refresh / Edit Libraries / Edit Path -// Replacements. Refresh is disabled while the Plex lock is held. +// Replacements; Refresh is disabled while the Plex lock is held. The budget-exhausted terminal (no +// servers may exist yet) instead offers a global "Check again" affordance (#367) so its message +// always points at a control that's actually rendered. export function PlexSourceScreen() { const [boot, setBoot] = useState({ status: 'loading' }); const [actionError, setActionError] = useState(null); const [confirmSignOut, setConfirmSignOut] = useState(false); const [signingOut, setSigningOut] = useState(false); const [refreshingId, setRefreshingId] = useState(null); + const [checkingAgain, setCheckingAgain] = useState(false); // Pin-flow UI state. const [pinState, setPinState] = useState(null); @@ -162,6 +165,41 @@ export function PlexSourceScreen() { }); }; + // The global "Check again" affordance for the budget-exhausted terminal (#367): that state has no + // per-server Refresh (the Servers card only renders once a server is discovered), so the message + // must point at something that actually exists. This does a single one-off re-check of + // GET /api/v1/media-sources/plex rather than resuming the timed poll loop — the 150s budget is + // spent; re-arming it silently would contradict "exhausted" and could spin forever on a sync that + // never finishes. + const checkAgain = () => { + if (checkingAgain) { + return; + } + setCheckingAgain(true); + setActionError(null); + getPlexState() + .then((state) => { + if (!activeRef.current) { + return; + } + setBoot({ status: 'ready', state }); + const elapsed = Date.now() - startedAtRef.current; + const next = evaluatePinFlow( + { isLocked: state.isLocked, isAuthorized: state.isAuthorized, hasServers: (state.servers ?? []).length > 0 }, + { budgetExhausted: isPinFlowBudgetExhausted(elapsed) } + ); + setPinState(next); + setCheckingAgain(false); + }) + .catch((error: unknown) => { + if (!activeRef.current) { + return; + } + setCheckingAgain(false); + setActionError(messageFromMediaSourcesError(error, 'Unable to check Plex status')); + }); + }; + const refresh = (serverId: number) => { setRefreshingId(serverId); setActionError(null); @@ -284,6 +322,18 @@ export function PlexSourceScreen() { {(popupBlocked || (polling && authUrl)) && authUrl && ( Open the Plex sign-in page )} + {pinState?.status === 'budget-exhausted' && ( + + )} )} {!polling && !pinState && !hasPlexAccount && (