Files
ersatztv/web/src/mediaSources/pinFlowPoll.ts
T
timothyandClaude Opus 4.8 6ce448d265 feat(spa): media-source SPA foundation — shared client, helpers, App-owned popstate wrapper (#202 slice S5)
Owns the shared single-files so the S6a (Local) / S6b (Remote) editor slices touch
disjoint files. Editor screens are stubbed (MediaSourceEditorPlaceholder) for S6.

- web/src/api/mediaSources.ts (+test): client module over the new media-source write
  endpoints (local CRUD + move/path-exists; Plex pin-flow/sign-out; shared remote
  state/connection/libraries/path-replacements/refresh; family = only URL variance),
  DTOs re-exported from generated v1, messageFromMediaSourcesError; barrel export.
- web/src/mediaSources/{familyMeta,paths,pinFlowPoll}.ts (+tests): family labels/routes/
  remote-path column naming (owns RemoteFamily); client-side NormalizePath mirror for
  in-draft dup detection; pure §C1 pin-flow poll state machine (waiting/finalizing/
  success/timeout/budget-exhausted), timer-free and fully unit-tested.
- routing.ts parseLibrariesSubRoute + LibrariesSubRoute union split Local vs Remote.
- App.tsx: libraries route allowSubPaths; LibrariesRouteScreen wrapper dispatching a
  flat switch to placeholders; App-owned popstate (finding 4) — App is the single
  popstate owner, consults canLeaveCurrentScreen() and only on approval updates
  librariesSubPath passed DOWN to the wrapper (wrapper never self-listens); state write
  scoped to the libraries route so Playouts/Media pops stay byte-identical (nit 3).
- LibrariesScreen hub wiring: Add-Source menu (Local/Plex/Jellyfin/Emby), remote source
  gear -> family screen, local library row gear -> edit route; removed the disabled
  Scan-All button + the deferred-sources card (§C7/§D.1).
- Tests: App-owned-popstate dirty-guard case (confirm false keeps URL+sub-screen; true
  navigates); mediaSources client URL/verb mapping; pinFlowPoll transitions; familyMeta/
  paths units; hub-wiring navigation.
- docs/spa-conventions.md §8 (resolved sub-path+dirty-guard caveat -> App-owned popstate)
  + §2 exemplar list (guarded-route exception).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:11:16 +02:00

91 lines
3.7 KiB
TypeScript

// The Plex OAuth pin-flow poll STATE MACHINE (design §C1 step 5), as a pure, timer-free module so
// the transitions can be unit-tested with injected state. The Plex screen (slice S6b) drives it:
// after `POST /pin-flow` it opens the auth URL and polls `GET /api/media-sources/plex` every 2s for
// up to 150s, feeding each observation ({ isLocked, isAuthorized }) here to decide what to show and
// whether to keep polling.
//
// KEY INSIGHT (finding 5): `isAuthorized` flips true the moment the token is saved, which is BEFORE
// SynchronizePlexMediaSources discovers the servers and releases the Plex lock. So authorization
// alone is NOT success — the terminal success signal is the lock RELEASING while authorized. Poll
// until the lock releases, not until authorized.
//
// isLocked & !isAuthorized -> waiting (keep polling; user hasn't authorized yet)
// isLocked & isAuthorized -> finalizing (keep polling; discovering servers)
// !isLocked & isAuthorized -> success (terminal)
// !isLocked & !isAuthorized -> timeout (terminal; flow ended without auth)
// isLocked (budget spent) -> budget-exhausted (terminal; a large first sync legitimately
// still holds the lock — not a failure)
export type PinFlowStatus = 'waiting' | 'finalizing' | 'success' | 'timeout' | 'budget-exhausted';
export interface PinFlowObservation {
isLocked: boolean;
isAuthorized: boolean;
}
export interface PinFlowState {
status: PinFlowStatus;
/** True once the machine has settled and polling should stop. */
done: boolean;
/** True only on the successful terminal state. */
ok: boolean;
/** User-facing message for the current state. */
message: string;
}
/** Poll cadence and total budget (design §C1: every 2s for up to 150s). */
export const PIN_FLOW_POLL_INTERVAL_MS = 2000;
export const PIN_FLOW_POLL_BUDGET_MS = 150_000;
const MESSAGES: Record<PinFlowStatus, string> = {
waiting: 'Waiting for you to authorize in the Plex tab…',
finalizing: 'Authorized — discovering your Plex servers…',
success: 'Connected to Plex.',
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.'
};
function stateFor(status: PinFlowStatus): PinFlowState {
return {
status,
done: status === 'success' || status === 'timeout' || status === 'budget-exhausted',
ok: status === 'success',
message: MESSAGES[status]
};
}
/** The state shown before the first poll observation arrives. */
export function initialPinFlowState(): PinFlowState {
return stateFor('waiting');
}
/** True when the elapsed poll time has consumed the whole budget. */
export function isPinFlowBudgetExhausted(elapsedMs: number): boolean {
return elapsedMs >= PIN_FLOW_POLL_BUDGET_MS;
}
/**
* Evaluate a single poll observation. `budgetExhausted` reflects whether the poll budget is spent;
* it only matters while the lock is still held (a released lock is always terminal, success or
* timeout, regardless of budget).
*/
export function evaluatePinFlow(
observation: PinFlowObservation,
options: { budgetExhausted?: boolean } = {}
): PinFlowState {
const { isLocked, isAuthorized } = observation;
// Lock released: terminal either way. Success iff authorized.
if (!isLocked) {
return stateFor(isAuthorized ? 'success' : 'timeout');
}
// Still locked but out of budget: not a failure, just "still working".
if (options.budgetExhausted) {
return stateFor('budget-exhausted');
}
// Still locked, budget remaining: waiting for auth, or finalizing after auth.
return stateFor(isAuthorized ? 'finalizing' : 'waiting');
}