From 880163ca7aec8ac9b6dd4e192d1044d69058131e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 2 May 2026 00:57:08 +0200 Subject: [PATCH] perf(portal-status): hoist 30s TTL cache + in-flight dedup into shared service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PortalStatusService previously did one IPC + HTTPS round-trip per call, forcing every consumer to roll its own cache (or, more often, not). The result: opening the homepage rendered N playlist-item components that each fired their own check, then opening the playlist switcher fired N more for the same portals. Move the cache and dedup into the service: - 30 s TTL cache keyed by `${serverUrl}|${username}|${password}`. Same credentials = same cache entry, regardless of which playlist row triggered it. - In-flight dedup via Map>. Two callers hitting the same portal in the same tick share one network request instead of racing. - New `getCachedStatus()` for sync read (used by playlist-switcher to hydrate the UI on menu open without awaiting). - New `clearStatusCache()` for log-out / debug flows. Add `{ skipCache: true }` opt-out for the Xtream import dialog's "Test Connection" button — that's a user-initiated check that must return fresh truth, not a 30 s old cached result. Net result: in the common flow (homepage → switcher), the switcher opens with cached status indicators instantly. The single in-flight dedup prevents the playlist-item ngOnInit + switcher onMenuOpened from racing for the same portal. Removed the component-local cache from playlist-switcher; service is now the single source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: a635db375527 --- .../xtream-code-import.component.ts | 6 +- .../playlist-switcher.component.spec.ts | 2 + .../playlist-switcher.component.ts | 40 ++----- .../services/src/lib/portal-status.service.ts | 109 ++++++++++++++++++ 4 files changed, 128 insertions(+), 29 deletions(-) diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index fecf0f634..ee897498e 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -97,11 +97,15 @@ export class XtreamCodeImportComponent { }`; try { + // User-initiated connection test — bypass the shared cache so the + // result reflects the portal's current state, not whatever was + // cached up to 30 s ago by another component. this.connectionStatus = await this.portalStatusService.checkPortalStatus( serverUrl, this.form.value.username as string, - this.form.value.password as string + this.form.value.password as string, + { skipCache: true } ); } finally { this.isTestingConnection = false; diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts index d04260cf4..0b074ffcd 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -53,6 +53,7 @@ describe('PlaylistSwitcherComponent', () => { }; let portalStatusService: { checkPortalStatus: jest.Mock; + getCachedStatus: jest.Mock; getStatusClass: jest.Mock; }; let refreshActionService: { @@ -172,6 +173,7 @@ describe('PlaylistSwitcherComponent', () => { .fn() .mockResolvedValueOnce('active') .mockResolvedValueOnce('inactive'), + getCachedStatus: jest.fn().mockReturnValue(null), getStatusClass: jest.fn((status: string) => `status-${status}`), }; refreshActionService = { diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 425b4e816..04711572c 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -180,23 +180,11 @@ export class PlaylistSwitcherComponent { readonly portalStatuses = signal>(new Map()); - /** - * 30-second TTL cache so that closing and reopening the menu within the - * window reuses prior portal status results instead of re-firing N - * IPC + HTTPS round-trips. Each entry is keyed by playlistId; the - * timestamp gates expiry independently of the in-memory portalStatuses - * signal (which can be cleared without invalidating the cache). - */ - private readonly portalStatusCache = new Map< - string, - { status: PortalStatus; timestamp: number } - >(); - private static readonly PORTAL_STATUS_CACHE_TTL_MS = 30_000; - /** * Tracks the in-flight check round so we can cancel pending writes when * the menu closes (avoiding zombie writes from a slow portal landing - * after the user has moved on). + * after the user has moved on). Status caching itself lives in + * PortalStatusService so it's shared across components. */ private portalStatusAbortController: AbortController | null = null; readonly currentLocale = computed(() => { @@ -430,18 +418,19 @@ export class PlaylistSwitcherComponent { return; } - const now = Date.now(); - const ttl = PlaylistSwitcherComponent.PORTAL_STATUS_CACHE_TTL_MS; - - // Hydrate from cache + mark uncached portals as 'checking' in a single - // signal write so the UI flips from blank → cached/checking dots in - // one render, not one render per playlist. + // Hydrate from the shared service cache + mark uncached portals as + // 'checking' in a single signal write so the UI flips from blank → + // cached/checking dots in one render, not one per playlist. const next = new Map(this.portalStatuses()); const toFetch: PlaylistMeta[] = []; for (const playlist of xtreamPlaylists) { - const cached = this.portalStatusCache.get(playlist._id); - if (cached && now - cached.timestamp < ttl) { - next.set(playlist._id, cached.status); + const cached = this.portalStatusService.getCachedStatus( + playlist.serverUrl, + playlist.username, + playlist.password + ); + if (cached !== null) { + next.set(playlist._id, cached); } else { next.set(playlist._id, 'checking'); toFetch.push(playlist); @@ -473,11 +462,6 @@ export class PlaylistSwitcherComponent { return; } - this.portalStatusCache.set(playlist._id, { - status, - timestamp: Date.now(), - }); - this.portalStatuses.update((current) => { const updated = new Map(current); updated.set(playlist._id, status); diff --git a/libs/services/src/lib/portal-status.service.ts b/libs/services/src/lib/portal-status.service.ts index 544146b4f..e3fe525e8 100644 --- a/libs/services/src/lib/portal-status.service.ts +++ b/libs/services/src/lib/portal-status.service.ts @@ -17,21 +17,130 @@ interface XtreamPortalStatusResponse { }; } +interface PortalStatusCacheEntry { + status: PortalStatus; + timestamp: number; +} + +interface CheckPortalStatusOptions { + /** + * Skip the cache and force a fresh round-trip. Use for explicit user + * actions like "Test Connection" buttons; default behavior (cache hit + * within TTL returns immediately) is correct for passive status checks. + */ + skipCache?: boolean; +} + +const PORTAL_STATUS_CACHE_TTL_MS = 30_000; + @Injectable({ providedIn: 'root', }) export class PortalStatusService { private readonly dataService = inject(DataService); + /** + * Process-lifetime cache shared across all consumers (playlist switcher, + * recent playlists item, etc.). Same credential triple = same cache + * entry, so opening the homepage and then the switcher within 30 s + * skips redundant IPC + HTTPS round-trips. + */ + private readonly cache = new Map(); + + /** + * Dedup in-flight requests so two near-simultaneous callers (homepage + * playlist-item + switcher menu open) share a single network round-trip + * instead of racing each other. + */ + private readonly inFlight = new Map>(); + /** * Checks the status of an Xtream Code portal * * @param serverUrl The base URL of the server * @param username The username for authentication * @param password The password for authentication + * @param options Pass `{ skipCache: true }` for user-initiated checks + * that must bypass the cache (e.g. "Test Connection") * @returns A promise that resolves to the portal status */ async checkPortalStatus( + serverUrl: string, + username: string, + password: string, + options?: CheckPortalStatusOptions + ): Promise { + const cacheKey = this.buildCacheKey(serverUrl, username, password); + + if (!options?.skipCache) { + const cached = this.cache.get(cacheKey); + if ( + cached && + Date.now() - cached.timestamp < PORTAL_STATUS_CACHE_TTL_MS + ) { + return cached.status; + } + + const pending = this.inFlight.get(cacheKey); + if (pending) { + return pending; + } + } + + const request = this.fetchPortalStatus(serverUrl, username, password) + .then((status) => { + this.cache.set(cacheKey, { + status, + timestamp: Date.now(), + }); + return status; + }) + .finally(() => { + this.inFlight.delete(cacheKey); + }); + + if (!options?.skipCache) { + this.inFlight.set(cacheKey, request); + } + + return request; + } + + /** + * Synchronous read of the cached status for a credential triple. + * Returns null if no entry exists or the entry has expired. + */ + getCachedStatus( + serverUrl: string, + username: string, + password: string + ): PortalStatus | null { + const cached = this.cache.get( + this.buildCacheKey(serverUrl, username, password) + ); + if (!cached) { + return null; + } + if (Date.now() - cached.timestamp >= PORTAL_STATUS_CACHE_TTL_MS) { + return null; + } + return cached.status; + } + + /** Clear the entire cache. Useful for log-out or debug flows. */ + clearStatusCache(): void { + this.cache.clear(); + } + + private buildCacheKey( + serverUrl: string, + username: string, + password: string + ): string { + return `${serverUrl}|${username}|${password}`; + } + + private async fetchPortalStatus( serverUrl: string, username: string, password: string