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>
91 lines
3.7 KiB
TypeScript
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');
|
|
}
|