perf(portal-status): hoist 30s TTL cache + in-flight dedup into shared service

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<key, Promise<PortalStatus>>. 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) <noreply@anthropic.com>
Entire-Checkpoint: a635db375527
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-02 00:57:08 +02:00
1 parent e888841b21
commit 880163ca7a
4 files changed
+128 -29

No files matched your search

@@ -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;
@@ -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 = {
@@ -180,23 +180,11 @@ export class PlaylistSwitcherComponent {
readonly portalStatuses = signal<Map<string, PortalStatus>>(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);
@@ -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<string, PortalStatusCacheEntry>();
/**
* 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<string, Promise<PortalStatus>>();
/**
* 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<PortalStatus> {
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