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