feat(playlist): show desktop health indicators for network sources (#1592)

This commit is contained in:
4gray authored and GitHub committed 2026-09-12 23:05:17 +02:00
1 parent a417826b01
commit 62655a8b5d
67 files changed
+2302 -60

No files matched your search

+2
View File
@@ -98,3 +98,5 @@ export * from './lib/vod-details-item.interface';
export * from './lib/catchup-download.interface';
export * from './lib/xtream-connection-test';
export * from './lib/source-health';
@@ -1,3 +1,4 @@
import type { SourceProbeContext, SourceHealthResult } from './source-health';
import type { XtreamConnectionFailure } from './xtream-connection-test';
import type {
CatchupDownloadMetadata,
@@ -291,6 +292,7 @@ export interface ElectronBridgeAiSettings {
}
export interface ElectronBridgeStalkerRequestPayload {
probe?: SourceProbeContext;
url: string;
macAddress: string;
params: Record<string, string>;
@@ -307,6 +309,7 @@ export interface ElectronBridgeStalkerRequestPayload {
}
export interface ElectronBridgeXtreamRequestPayload {
probe?: SourceProbeContext;
connectionTest?: boolean;
url: string;
params: Record<string, string>;
@@ -888,6 +891,13 @@ export interface ElectronBridgeApi {
method?: 'GET' | 'HEAD'
) => Promise<ElectronBridgeXtreamProbeResult>;
/** Generic stream reachability probe (VOD multi-source availability) */
cancelSourceProbe: (id: string) => Promise<void>;
probeM3uSource: (payload: {
url: string;
userAgent?: string;
trustedInsecureTlsHosts?: string[];
probe: SourceProbeContext;
}) => Promise<SourceHealthResult>;
probeStreamUrl: (
url: string,
method?: 'GET' | 'HEAD',
@@ -0,0 +1,62 @@
import {
accountHealth,
sourceHealthError,
sourceHealthKey,
sourceHealthType,
} from './source-health';
describe('source health evidence', () => {
it.each(['inactive', 'disabled', 'banned', 'expired'])(
'accepts explicit %s as a cleanup candidate',
(status) => {
expect(accountHealth(status).confirmedInactive).toBe(true);
}
);
it.each([
'Authorization failed.',
'HTTP Error 403: Forbidden',
'request timeout',
'connection cooldown',
'network error',
])('does not preselect %s', (message) => {
expect(sourceHealthError(new Error(message)).confirmedInactive).toBe(
false
);
});
it('recognizes the shared guard refusal without classifying the hostname as auth failure', () => {
expect(
sourceHealthError(
new Error(
'Portal authorization.example:8080 is not responding; skipped after repeated connection failures'
)
).reason
).toBe('paused');
});
it('does not treat unknown account statuses as disabled', () => {
expect(accountHealth('2').confirmedInactive).toBe(false);
expect(accountHealth(undefined, undefined, true).state).toBe('active');
});
it('detects expiry only from a real timestamp', () => {
expect(accountHealth('active', Date.now() / 1000 - 60).state).toBe(
'expired'
);
expect(accountHealth('active').state).toBe('active');
});
it('excludes local files and text, keys by connection rather than title', () => {
expect(
sourceHealthType({ _id: 'x', filePath: '/tmp/test.m3u' })
).toBeNull();
const p = {
_id: 'a',
url: 'https://example.test/list',
userAgent: 'A',
};
expect(sourceHealthType(p)).toBe('m3u');
expect(sourceHealthKey(p)).toBe(
sourceHealthKey({ ...p, title: 'Renamed' })
);
expect(sourceHealthKey(p)).not.toBe(
sourceHealthKey({ ...p, userAgent: 'B' })
);
});
});
@@ -0,0 +1,118 @@
import { isHostConnectivityFastFailMessage } from './host-connectivity.util';
import { isStalkerAuthFailureMessage } from './stalker-auth-failure.util';
import type { PlaylistMeta } from './playlist-meta.type';
export type SourceHealthState =
'unknown' | 'checking' | 'active' | 'inactive' | 'expired' | 'unavailable';
export type SourceHealthReason =
| 'available'
| 'disabled'
| 'expired'
| 'auth'
| 'http'
| 'timeout'
| 'network'
| 'paused'
| 'invalid'
| 'unknown'
| 'cancelled';
export interface SourceHealthResult {
state: SourceHealthState;
reason: SourceHealthReason;
expiresAtSeconds?: number;
httpStatus?: number;
confirmedInactive: boolean;
}
export interface SourceHealthSnapshot extends SourceHealthResult {
checkedAt: number;
lastSuccessAt?: number;
}
export interface SourceProbeContext {
requestId: string;
deadlineAt: number;
}
export const SOURCE_HEALTH_CANCEL = 'SOURCE_HEALTH_CANCEL';
export const M3U_SOURCE_PROBE = 'M3U_SOURCE_PROBE';
export function sourceHealthUnknown(
reason: SourceHealthReason = 'unknown'
): SourceHealthResult {
return { state: 'unavailable', reason, confirmedInactive: false };
}
export function sourceHealthType(
p: Partial<PlaylistMeta>
): 'stalker' | 'xtream' | 'm3u' | null {
if (p.macAddress && p.portalUrl) return 'stalker';
if (p.serverUrl && p.username && p.password) return 'xtream';
return p.url && /^https?:\/\//i.test(p.url) ? 'm3u' : null;
}
export function sourceHealthKey(p: Partial<PlaylistMeta>): string {
return JSON.stringify([
sourceHealthType(p),
p.url,
p.serverUrl,
p.portalUrl,
p.username,
p.password,
p.macAddress,
p.userAgent,
p.isFullStalkerPortal,
p.stalkerSerialNumber,
p.stalkerDeviceId1,
p.stalkerDeviceId2,
p.stalkerSignature1,
p.stalkerSignature2,
]);
}
export function accountHealth(
status: unknown,
expiration?: number,
authenticated = false
): SourceHealthResult {
const value = String(status ?? '')
.trim()
.toLowerCase();
if (value === 'expired' || (expiration && expiration * 1000 < Date.now())) {
return {
state: 'expired',
reason: 'expired',
confirmedInactive: true,
expiresAtSeconds: expiration,
};
}
if (['disabled', 'banned', 'inactive'].includes(value)) {
return {
state: 'inactive',
reason: 'disabled',
confirmedInactive: true,
expiresAtSeconds: expiration,
};
}
if (value === 'active' || authenticated) {
return {
state: 'active',
reason: 'available',
confirmedInactive: false,
expiresAtSeconds: expiration,
};
}
return sourceHealthUnknown();
}
export function sourceHealthError(error: unknown): SourceHealthResult {
const text = error instanceof Error ? error.message : String(error);
if (/abort|cancel/i.test(text)) return sourceHealthUnknown('cancelled');
if (
isHostConnectivityFastFailMessage(text) ||
/guard|cooldown|paused|circuit/i.test(text)
)
return sourceHealthUnknown('paused');
if (/timeout|timed out|deadline/i.test(text))
return sourceHealthUnknown('timeout');
if (
isStalkerAuthFailureMessage(text) ||
(text.toLowerCase().includes('http error') &&
(text.includes('401') || text.includes('403')))
)
return { ...sourceHealthUnknown('auth'), state: 'inactive' };
if (/HTTP Error/i.test(text)) return sourceHealthUnknown('http');
return sourceHealthUnknown('network');
}