mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
e888841b21
commit
880163ca7a
4 files changed
+128
-29
No files matched your search
+5
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user