mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
feat(playlist): show desktop health indicators for network sources (#1592)
This commit is contained in:
1 parent
a417826b01
commit
62655a8b5d
67 files changed
+2302
-60
No files matched your search
@@ -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');
|
||||
}
|
||||
Reference in new issue
Block a user