From 7d0e8f69cacde9ff09440decc329211acc209e1d Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 04:02:40 +0200 Subject: [PATCH] perf(sources): keep source-health helpers off the startup path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master's #1931 lowered the initial-bytes baseline, and this branch then sat 535 bytes over its slack. Most of its growth was runtime helpers in `@iptvnator/shared/interfaces` (reachability, health history, reason and fingerprint helpers) that only the lazily loaded source services call: the bundler put them in a chunk the app preloads. They now live in `@iptvnator/portal/shared/data-access` beside those services (types stay in interfaces); initialBytes 1,350,438 → 1,349,048, 855 under the limit. Co-Authored-By: Claude Opus 5.5 --- .../playlist-switcher-panel.component.ts | 11 +- .../recent-playlists.component.ts | 3 +- libs/portal/shared/data-access/src/index.ts | 1 + .../src/lib/source-health-history.service.ts | 8 +- .../src/lib/source-health-identity.ts | 44 +++++++ .../src/lib/source-health.service.ts | 2 +- .../src/lib/source-reachability.spec.ts | 0 .../src/lib/source-reachability.ts | 116 ++++++++++++++++++ .../src/lib/source-status.service.spec.ts | 2 +- .../src/lib/source-status.service.ts | 10 +- .../interfaces/src/lib/source-health.ts | 39 ------ .../interfaces/src/lib/source-reachability.ts | 106 +--------------- ...rkspace-sources-filters-panel.component.ts | 10 +- .../workspace-sources.component.ts | 6 +- 14 files changed, 191 insertions(+), 167 deletions(-) create mode 100644 libs/portal/shared/data-access/src/lib/source-health-identity.ts rename libs/{shared/interfaces => portal/shared/data-access}/src/lib/source-reachability.spec.ts (100%) create mode 100644 libs/portal/shared/data-access/src/lib/source-reachability.ts diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts index 8306de236..9f31f197c 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts @@ -21,7 +21,10 @@ import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { Router } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; -import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { + SourceStatusService, + sourceStatusBucket, +} from '@iptvnator/portal/shared/data-access'; import { countSourcesByType, orderFailingFirst, @@ -37,11 +40,7 @@ import { SortService, SourceActivityService, } from '@iptvnator/services'; -import { - PlaylistMeta, - PlaylistSourceKind, - sourceStatusBucket, -} from '@iptvnator/shared/interfaces'; +import { PlaylistMeta, PlaylistSourceKind } from '@iptvnator/shared/interfaces'; import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component'; import { SourceRemovalService } from '../source-removal/source-removal.service'; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 7c955d852..6511a2b05 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -26,6 +26,7 @@ import { import { keepSourcesChecked, SourceStatusService, + sourceStatusBucket, } from '@iptvnator/portal/shared/data-access'; import { filterSources, @@ -42,7 +43,7 @@ import { SortService, SourceActivityService, } from '@iptvnator/services'; -import { PlaylistMeta, sourceStatusBucket } from '@iptvnator/shared/interfaces'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import type { PlaylistType } from '../add-playlist-menu/playlist-type'; import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; import { SourceRemovalService } from '../source-removal/source-removal.service'; diff --git a/libs/portal/shared/data-access/src/index.ts b/libs/portal/shared/data-access/src/index.ts index dab20ee0e..bf28db4c4 100644 --- a/libs/portal/shared/data-access/src/index.ts +++ b/libs/portal/shared/data-access/src/index.ts @@ -7,6 +7,7 @@ export * from './lib/source-health.service'; export * from './lib/source-health-history.service'; export * from './lib/source-status.service'; export * from './lib/source-status-checks'; +export { sourceStatusBucket } from './lib/source-reachability'; export * from './lib/source-cleanup.service'; diff --git a/libs/portal/shared/data-access/src/lib/source-health-history.service.ts b/libs/portal/shared/data-access/src/lib/source-health-history.service.ts index 3d251f63d..d25bad16e 100644 --- a/libs/portal/shared/data-access/src/lib/source-health-history.service.ts +++ b/libs/portal/shared/data-access/src/lib/source-health-history.service.ts @@ -1,12 +1,14 @@ import { Injectable, inject, signal } from '@angular/core'; import { SourceHealthEvidenceService } from '@iptvnator/services'; import { - isSourceHealthReason, - nextSourceHealthHistory, SourceHealthHistoryEntry, SourceHealthSnapshot, - sourceReachabilityFromHealth, } from '@iptvnator/shared/interfaces'; +import { isSourceHealthReason } from './source-health-identity'; +import { + nextSourceHealthHistory, + sourceReachabilityFromHealth, +} from './source-reachability'; export const SOURCE_HEALTH_HISTORY_STORAGE_KEY = 'source-health:history:v1'; diff --git a/libs/portal/shared/data-access/src/lib/source-health-identity.ts b/libs/portal/shared/data-access/src/lib/source-health-identity.ts new file mode 100644 index 000000000..fed8b9f84 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/source-health-identity.ts @@ -0,0 +1,44 @@ +import { + sourceHealthKey, + type PlaylistMeta, + type SourceHealthReason, +} from '@iptvnator/shared/interfaces'; + +const SOURCE_HEALTH_REASONS: ReadonlySet = new Set([ + 'available', + 'disabled', + 'expired', + 'auth', + 'http', + 'timeout', + 'network', + 'paused', + 'invalid', + 'unknown', + 'cancelled', +]); + +/** Whether stored data names a reason this build can describe. */ +export function isSourceHealthReason( + value: unknown +): value is SourceHealthReason { + return typeof value === 'string' && SOURCE_HEALTH_REASONS.has(value); +} + +/** + * A 32-bit FNV-1a fingerprint of a source health key: enough to tell that a + * source now points somewhere else, without storing where (or with which + * credentials). Kept with the persisted health history. + */ +export function fingerprintSourceHealthKey(key: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < key.length; index += 1) { + hash ^= key.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +export function sourceHealthFingerprint(p: Partial): string { + return fingerprintSourceHealthKey(sourceHealthKey(p)); +} diff --git a/libs/portal/shared/data-access/src/lib/source-health.service.ts b/libs/portal/shared/data-access/src/lib/source-health.service.ts index ccaed69f4..2d662145f 100644 --- a/libs/portal/shared/data-access/src/lib/source-health.service.ts +++ b/libs/portal/shared/data-access/src/lib/source-health.service.ts @@ -4,7 +4,6 @@ import { RuntimeCapabilitiesService, } from '@iptvnator/services'; import { - fingerprintSourceHealthKey, PlaylistMeta, SourceHealthSnapshot, SourceHealthResult, @@ -13,6 +12,7 @@ import { sourceHealthUnknown, sourceHealthError, } from '@iptvnator/shared/interfaces'; +import { fingerprintSourceHealthKey } from './source-health-identity'; import { SourceHealthHistoryService } from './source-health-history.service'; import { SourceHealthProbesService } from './source-health-probes.service'; diff --git a/libs/shared/interfaces/src/lib/source-reachability.spec.ts b/libs/portal/shared/data-access/src/lib/source-reachability.spec.ts similarity index 100% rename from libs/shared/interfaces/src/lib/source-reachability.spec.ts rename to libs/portal/shared/data-access/src/lib/source-reachability.spec.ts diff --git a/libs/portal/shared/data-access/src/lib/source-reachability.ts b/libs/portal/shared/data-access/src/lib/source-reachability.ts new file mode 100644 index 000000000..a62c043ed --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/source-reachability.ts @@ -0,0 +1,116 @@ +import type { + ConclusiveSourceReachability, + SourceHealthHistoryEntry, + SourceHealthReason, + SourceHealthSnapshot, + SourceReachability, + SourceStatusBucket, +} from '@iptvnator/shared/interfaces'; + +// Runtime helpers live here, beside the lazily loaded services that use +// them: in `@iptvnator/shared/interfaces` they rode into a chunk the app +// preloads at startup. + +/** + * Health reasons that mean the source itself did not answer properly. A + * paused, cancelled or unknown check says nothing about the source. + */ +const UNREACHABLE_REASONS = new Set(['network', 'timeout', 'http', 'invalid']); + +export function sourceReachabilityFromHealth( + snapshot: Pick | undefined +): SourceReachability { + switch (snapshot?.state) { + case 'active': + return 'ok'; + case 'expired': + return 'expired'; + case 'inactive': + return 'bad'; + case 'checking': + return 'checking'; + case 'unavailable': + return UNREACHABLE_REASONS.has(snapshot.reason) ? 'bad' : 'unknown'; + default: + return 'unknown'; + } +} + +/** The browser build's Xtream portal status, by the same rules. */ +export function sourceReachabilityFromPortalStatus( + status: 'active' | 'inactive' | 'expired' | 'unavailable' | 'checking' +): SourceReachability { + switch (status) { + case 'active': + return 'ok'; + case 'expired': + return 'expired'; + case 'checking': + return 'checking'; + default: + return 'bad'; + } +} + +export function isConclusiveReachability( + reachability: SourceReachability +): reachability is ConclusiveSourceReachability { + return ( + reachability === 'ok' || + reachability === 'bad' || + reachability === 'expired' + ); +} + +/** + * Expired accounts count as not responding: the source answers, but the + * account cannot play anything, which is what the filter is for. + */ +export function sourceStatusBucket( + reachability: SourceReachability +): SourceStatusBucket | null { + if (reachability === 'ok') { + return 'ok'; + } + return reachability === 'bad' || reachability === 'expired' ? 'bad' : null; +} + +/** + * Folds one check into the kept history. An inconclusive check keeps the + * previous entry; a failure keeps the start of the current failing run, so + * a source that has not answered for three days still says three days after + * every later failed check. + */ +export function nextSourceHealthHistory( + kept: SourceHealthHistoryEntry | undefined, + reachability: SourceReachability, + checkedAt: number, + detail: { reason?: SourceHealthReason; connection?: string } = {} +): SourceHealthHistoryEntry | undefined { + const { reason, connection } = detail; + // A check of another connection starts the history over. + const previous = + kept?.connection && connection && kept.connection !== connection + ? undefined + : kept; + if (!isConclusiveReachability(reachability)) { + return previous; + } + const checked = connection ? { connection } : {}; + if (reachability === 'ok') { + return { state: 'ok', checkedAt, lastSuccessAt: checkedAt, ...checked }; + } + const wasFailing = previous !== undefined && previous.state !== 'ok'; + return { + state: reachability, + checkedAt, + failingSince: wasFailing + ? (previous.failingSince ?? previous.checkedAt) + : checkedAt, + ...(previous?.lastSuccessAt !== undefined + ? { lastSuccessAt: previous.lastSuccessAt } + : {}), + ...(reason ? { reason } : {}), + ...checked, + }; +} diff --git a/libs/portal/shared/data-access/src/lib/source-status.service.spec.ts b/libs/portal/shared/data-access/src/lib/source-status.service.spec.ts index 213295db4..ca90263e5 100644 --- a/libs/portal/shared/data-access/src/lib/source-status.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/source-status.service.spec.ts @@ -7,9 +7,9 @@ import { import { PlaylistMeta, SourceHealthHistoryEntry, - sourceHealthFingerprint, SourceHealthSnapshot, } from '@iptvnator/shared/interfaces'; +import { sourceHealthFingerprint } from './source-health-identity'; import { SourceHealthHistoryService } from './source-health-history.service'; import { SourceHealthService } from './source-health.service'; import { SourceStatusService } from './source-status.service'; diff --git a/libs/portal/shared/data-access/src/lib/source-status.service.ts b/libs/portal/shared/data-access/src/lib/source-status.service.ts index a71cfd38d..4a908d4e3 100644 --- a/libs/portal/shared/data-access/src/lib/source-status.service.ts +++ b/libs/portal/shared/data-access/src/lib/source-status.service.ts @@ -5,17 +5,19 @@ import { RuntimeCapabilitiesService, } from '@iptvnator/services'; import { - isConclusiveReachability, PlaylistMeta, SourceHealthReason, - sourceHealthFingerprint, sourceHealthType, - sourceReachabilityFromHealth, - sourceReachabilityFromPortalStatus, SourceStatus, } from '@iptvnator/shared/interfaces'; +import { sourceHealthFingerprint } from './source-health-identity'; import { SourceHealthHistoryService } from './source-health-history.service'; import { SourceHealthService } from './source-health.service'; +import { + isConclusiveReachability, + sourceReachabilityFromHealth, + sourceReachabilityFromPortalStatus, +} from './source-reachability'; type XtreamAccount = PlaylistMeta & { serverUrl: string; diff --git a/libs/shared/interfaces/src/lib/source-health.ts b/libs/shared/interfaces/src/lib/source-health.ts index 73f504871..f423df48e 100644 --- a/libs/shared/interfaces/src/lib/source-health.ts +++ b/libs/shared/interfaces/src/lib/source-health.ts @@ -16,27 +16,6 @@ export type SourceHealthReason = | 'invalid' | 'unknown' | 'cancelled'; -const SOURCE_HEALTH_REASONS: ReadonlySet = new Set([ - 'available', - 'disabled', - 'expired', - 'auth', - 'http', - 'timeout', - 'network', - 'paused', - 'invalid', - 'unknown', - 'cancelled', -]); - -/** Whether stored data names a reason this build can describe. */ -export function isSourceHealthReason( - value: unknown -): value is SourceHealthReason { - return typeof value === 'string' && SOURCE_HEALTH_REASONS.has(value); -} - export interface SourceHealthResult { state: SourceHealthState; reason: SourceHealthReason; @@ -84,24 +63,6 @@ export function sourceHealthKey(p: Partial): string { p.stalkerSignature2, ]); } -/** - * A 32-bit FNV-1a fingerprint of a source health key: enough to tell that a - * source now points somewhere else, without storing where (or with which - * credentials). Kept with the persisted health history. - */ -export function fingerprintSourceHealthKey(key: string): string { - let hash = 0x811c9dc5; - for (let index = 0; index < key.length; index += 1) { - hash ^= key.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, '0'); -} - -export function sourceHealthFingerprint(p: Partial): string { - return fingerprintSourceHealthKey(sourceHealthKey(p)); -} - export function accountHealth( status: unknown, expiration?: number, diff --git a/libs/shared/interfaces/src/lib/source-reachability.ts b/libs/shared/interfaces/src/lib/source-reachability.ts index 65a082ecc..e00f365c5 100644 --- a/libs/shared/interfaces/src/lib/source-reachability.ts +++ b/libs/shared/interfaces/src/lib/source-reachability.ts @@ -1,4 +1,4 @@ -import type { SourceHealthReason, SourceHealthSnapshot } from './source-health'; +import type { SourceHealthReason } from './source-health'; /** * What a source list shows about one network source, folded from the six @@ -33,70 +33,6 @@ export interface SourceStatus { reason?: SourceHealthReason; } -/** - * Health reasons that mean the source itself did not answer properly. A - * paused, cancelled or unknown check says nothing about the source. - */ -const UNREACHABLE_REASONS = new Set(['network', 'timeout', 'http', 'invalid']); - -export function sourceReachabilityFromHealth( - snapshot: Pick | undefined -): SourceReachability { - switch (snapshot?.state) { - case 'active': - return 'ok'; - case 'expired': - return 'expired'; - case 'inactive': - return 'bad'; - case 'checking': - return 'checking'; - case 'unavailable': - return UNREACHABLE_REASONS.has(snapshot.reason) ? 'bad' : 'unknown'; - default: - return 'unknown'; - } -} - -/** The browser build's Xtream portal status, by the same rules. */ -export function sourceReachabilityFromPortalStatus( - status: 'active' | 'inactive' | 'expired' | 'unavailable' | 'checking' -): SourceReachability { - switch (status) { - case 'active': - return 'ok'; - case 'expired': - return 'expired'; - case 'checking': - return 'checking'; - default: - return 'bad'; - } -} - -export function isConclusiveReachability( - reachability: SourceReachability -): reachability is ConclusiveSourceReachability { - return ( - reachability === 'ok' || - reachability === 'bad' || - reachability === 'expired' - ); -} - -/** - * Expired accounts count as not responding: the source answers, but the - * account cannot play anything, which is what the filter is for. - */ -export function sourceStatusBucket( - reachability: SourceReachability -): SourceStatusBucket | null { - if (reachability === 'ok') { - return 'ok'; - } - return reachability === 'bad' || reachability === 'expired' ? 'bad' : null; -} - /** The last conclusive result kept for a source across restarts. */ export interface SourceHealthHistoryEntry { state: ConclusiveSourceReachability; @@ -113,43 +49,3 @@ export interface SourceHealthHistoryEntry { */ connection?: string; } - -/** - * Folds one check into the kept history. An inconclusive check keeps the - * previous entry; a failure keeps the start of the current failing run, so - * a source that has not answered for three days still says three days after - * every later failed check. - */ -export function nextSourceHealthHistory( - kept: SourceHealthHistoryEntry | undefined, - reachability: SourceReachability, - checkedAt: number, - detail: { reason?: SourceHealthReason; connection?: string } = {} -): SourceHealthHistoryEntry | undefined { - const { reason, connection } = detail; - // A check of another connection starts the history over. - const previous = - kept?.connection && connection && kept.connection !== connection - ? undefined - : kept; - if (!isConclusiveReachability(reachability)) { - return previous; - } - const checked = connection ? { connection } : {}; - if (reachability === 'ok') { - return { state: 'ok', checkedAt, lastSuccessAt: checkedAt, ...checked }; - } - const wasFailing = previous !== undefined && previous.state !== 'ok'; - return { - state: reachability, - checkedAt, - failingSince: wasFailing - ? (previous.failingSince ?? previous.checkedAt) - : checkedAt, - ...(previous?.lastSuccessAt !== undefined - ? { lastSuccessAt: previous.lastSuccessAt } - : {}), - ...(reason ? { reason } : {}), - ...checked, - }; -} diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts index fbeaa0523..a8130a2ef 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources-filters-panel/workspace-sources-filters-panel.component.ts @@ -10,7 +10,10 @@ import { MatTooltip } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; -import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { + SourceStatusService, + sourceStatusBucket, +} from '@iptvnator/portal/shared/data-access'; import { SourceRemovalService } from '@iptvnator/playlist/shared/ui'; import { countSourcesByStatus, @@ -20,10 +23,7 @@ import { SourcesTypeFilter, SourcesViewStateService, } from '@iptvnator/playlist/shared/util'; -import { - SOURCE_TYPE_ICONS, - sourceStatusBucket, -} from '@iptvnator/shared/interfaces'; +import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces'; import { WORKSPACE_SHELL_ACTIONS, WorkspaceShellContextDrawerService, diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts index baf1f6fcd..6ed0ce9a6 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts @@ -18,7 +18,10 @@ import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { map } from 'rxjs'; import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; -import { SourceStatusService } from '@iptvnator/portal/shared/data-access'; +import { + SourceStatusService, + sourceStatusBucket, +} from '@iptvnator/portal/shared/data-access'; import { isLiveExternalPlayerSession, PORTAL_EXTERNAL_PLAYBACK, @@ -43,7 +46,6 @@ import { import { PlaylistUpdateState, sourceHealthType, - sourceStatusBucket, } from '@iptvnator/shared/interfaces'; import { MenuItemRadioCheckDirective,