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

@@ -0,0 +1,6 @@
---
type: feature
area: playlist
---
Desktop playlist lists now show availability for Stalker portals and M3U links alongside Xtream accounts. Checks share cached results, run in the background, and explain when a source could not be verified.
+9
View File
@@ -1147,3 +1147,12 @@ Archive transfers validate TS framing, restart from byte zero after interruption
and check expiry again at transfer start. Completed cards play locally and never
route to VOD details. Contract and EOF/duration limits:
`docs/architecture/download-manager.md` (Xtream archive downloads).
## Desktop Source Health
Electron switcher/source rows share bounded, cached Xtream/Stalker/M3U URL
checks through `SourceHealthService` in portal shared data access. Confirmed
account expiry/disablement is distinct from failed authorization or network
checks. Stalker probes reuse session ownership without endpoint repair; M3U
reads stop at 64 KiB. PWA retains existing Xtream behavior. Contract:
`docs/architecture/m3u-playlist-module.md` (Desktop source health).
+9
View File
@@ -1952,3 +1952,12 @@ Archive transfers validate TS framing, restart from byte zero after interruption
and check expiry again at transfer start. Completed cards play locally and never
route to VOD details. Contract and EOF/duration limits:
`docs/architecture/download-manager.md` (Xtream archive downloads).
## Desktop Source Health
Electron switcher/source rows share bounded, cached Xtream/Stalker/M3U URL
checks through `SourceHealthService` in portal shared data access. Confirmed
account expiry/disablement is distinct from failed authorization or network
checks. Stalker probes reuse session ownership without endpoint repair; M3U
reads stop at 64 KiB. PWA retains existing Xtream behavior. Contract:
`docs/architecture/m3u-playlist-module.md` (Desktop source health).
@@ -1594,6 +1594,12 @@ export async function openSourceEditor(
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
// Material applies its initial focus after the opening transition.
await dialog.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true }).map((animation) =>
animation.finished.catch(() => undefined)
));
});
return dialog;
}
+27 -4
View File
@@ -91,7 +91,7 @@ test.describe('Electron Sources View', () => {
await dialog
.getByRole('button', { name: 'Add', exact: true })
.click();
await page.waitForURL(/xtreams.*vod/);
await waitForXtreamCatalog(page);
await openSources(page);
dialog = await openSourceEditor(page, title);
await dialog.locator('[formControlName="password"]').fill('');
@@ -170,7 +170,7 @@ test.describe('Electron Sources View', () => {
);
expect(saved?.serverUrl).toBe(liveFormatMock);
await refreshSource(page, title, { confirm: true });
await waitForSourceRowIdle(page, title);
await waitForXtreamCatalog(page);
const refreshed = await waitForPortalDebugEvent(page, {
provider: 'xtream',
operation: 'get_live_streams',
@@ -532,6 +532,17 @@ https://streams.example.test/url-omega.m3u8
});
await openSources(app.mainWindow);
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator(
'app-source-health-indicator [data-state]'
)
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator(
'app-source-health-indicator [data-state]'
)
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
await expect(
sourceRowByTitle(
@@ -660,14 +671,26 @@ https://streams.example.test/original-url.m3u8
https://streams.example.test/refreshed-url.m3u8
`
);
// A previous refresh toast must not satisfy this operation's completion.
await expect(
app.mainWindow.locator('.mat-mdc-snack-bar-label')
).toHaveCount(0);
await refreshSource(app.mainWindow, 'refresh-url-source.m3u');
await expectPlaylistUpdatedToast(app.mainWindow);
await waitForSourceRowIdle(
app.mainWindow,
'refresh-url-source.m3u'
);
await sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
.first()
const refreshedUrlRow = sourceRowByTitle(
app.mainWindow,
'refresh-url-source.m3u'
).first();
await expect(
refreshedUrlRow.locator('.playlist-item')
).not.toHaveClass(/is-busy/);
await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled();
await refreshedUrlRow
.getByText('refresh-url-source.m3u', { exact: true })
.click();
await waitForM3uCatalog(app.mainWindow);
await expect(
@@ -1,3 +1,4 @@
import type { SourceProbeContext } from '@iptvnator/shared/interfaces';
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import {
APP_UPDATE_CHECK,
@@ -713,7 +714,12 @@ const electronApi: ElectronBridgeApi = {
updateSettings: (settings: Partial<Settings>) =>
ipcRenderer.invoke('SETTINGS_UPDATE', settings),
getAiSettings: () => ipcRenderer.invoke('GET_AI_SETTINGS'),
cancelSourceProbe: (id: string) =>
ipcRenderer.invoke('SOURCE_HEALTH_CANCEL', id),
probeM3uSource: (payload: unknown) =>
ipcRenderer.invoke('M3U_SOURCE_PROBE', payload),
stalkerRequest: (payload: {
probe?: SourceProbeContext;
url: string;
macAddress: string;
params: Record<string, string>;
@@ -725,6 +731,7 @@ const electronApi: ElectronBridgeApi = {
resetHostConnectivityGuard: (url: string) =>
ipcRenderer.invoke('CONNECTIVITY_GUARD_RESET', { url }),
xtreamRequest: (payload: {
probe?: SourceProbeContext;
connectionTest?: boolean;
url: string;
params: Record<string, string>;
@@ -0,0 +1,61 @@
import { Readable } from 'node:stream';
import { probeM3uSource } from './m3u-source-probe';
import { requestWithValidatedRedirects } from '../util/validated-axios';
jest.mock('electron', () => ({ ipcMain: { handle: jest.fn() } }));
jest.mock('../util/validated-axios', () => ({
requestWithValidatedRedirects: jest.fn(),
}));
jest.mock('../util/secure-https', () => ({
createPlaylistAgentFactory: jest.fn(),
}));
const request = jest.mocked(requestWithValidatedRedirects);
const payload = () => ({
url: 'https://example.test/list',
userAgent: 'TestAgent',
probe: { requestId: 'test', deadlineAt: Date.now() + 5000 },
});
describe('bounded M3U source probe', () => {
beforeEach(() => jest.clearAllMocks());
function answer(body: string, status = 200) {
const stream = Readable.from([Buffer.from(body)]);
request.mockResolvedValueOnce({ data: stream, status } as never);
return stream;
}
it('checks content and closes the stream without parsing the full list', async () => {
const stream = answer('#EXTM3U\n' + 'x'.repeat(100000));
expect((await probeM3uSource(payload(), 1)).state).toBe('active');
expect(stream.destroyed).toBe(true);
expect(request).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: { 'User-Agent': 'TestAgent', Range: 'bytes=0-65535' },
signal: expect.any(AbortSignal),
}),
expect.any(Object)
);
});
it('rejects HTML served with HTTP 200', async () => {
answer('<html>login required</html>');
expect((await probeM3uSource(payload(), 1)).reason).toBe('invalid');
});
it('falls back only after an explicit range refusal', async () => {
const first = answer('', 416);
answer('#EXTM3U\n');
expect((await probeM3uSource(payload(), 1)).state).toBe('active');
expect(first.destroyed).toBe(true);
expect(request.mock.calls[1][1]?.headers).not.toHaveProperty('Range');
});
it('does not retry an auth failure or preselect it for deletion', async () => {
answer('Forbidden', 403);
expect(await probeM3uSource(payload(), 1)).toMatchObject({
reason: 'auth',
confirmedInactive: false,
});
expect(request).toHaveBeenCalledTimes(1);
});
it('bounds a response that ignores Range', async () => {
const stream = answer('x'.repeat(1000000));
expect((await probeM3uSource(payload(), 1)).reason).toBe('unknown');
expect(stream.destroyed).toBe(true);
});
});
@@ -0,0 +1,107 @@
import { ipcMain } from 'electron';
import type { Readable } from 'node:stream';
import {
M3U_SOURCE_PROBE,
SourceProbeContext,
SourceHealthResult,
sourceHealthUnknown,
} from '@iptvnator/shared/interfaces';
import { createPlaylistAgentFactory } from '../util/secure-https';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import { sourceProbeControl } from './source-probe-control';
const LIMIT = 65536;
export function looksLikeM3u(text: string): boolean {
const value = text.replace(/^\uFEFF/, '').trimStart();
return (
/^#EXTM3U(?:\s|$)/.test(value) ||
/^#EXTINF:[^\r\n]*\r?\n(?:#[^\r\n]*\r?\n)*[^#\s<][^\r\n]*/.test(value)
);
}
export async function probeM3uSource(
payload: {
url: string;
userAgent?: string;
trustedInsecureTlsHosts?: string[];
probe: SourceProbeContext;
},
owner: number
): Promise<SourceHealthResult> {
const control = sourceProbeControl(owner, payload.probe);
let stream: Readable | undefined;
try {
for (const ranged of [true, false]) {
const response = await requestWithValidatedRedirects<Readable>(
payload.url,
{
method: 'GET',
responseType: 'stream',
signal: control.signal,
timeout: Math.max(1, payload.probe.deadlineAt - Date.now()),
agentFactory: createPlaylistAgentFactory({
trustedInsecureTlsHosts:
payload.trustedInsecureTlsHosts,
}),
headers: {
...(payload.userAgent?.trim()
? { 'User-Agent': payload.userAgent.trim() }
: {}),
...(ranged ? { Range: `bytes=0-${LIMIT - 1}` } : {}),
},
validateStatus: () => true,
},
{ allowPrivateNetworks: true }
);
stream = response.data;
if (ranged && [400, 405, 416].includes(response.status)) {
stream.destroy();
continue;
}
if (response.status >= 400)
return {
...sourceHealthUnknown(
[401, 403].includes(response.status) ? 'auth' : 'http'
),
state: [401, 403].includes(response.status)
? 'inactive'
: 'unavailable',
httpStatus: response.status,
};
let body = Buffer.alloc(0);
for await (const chunk of stream) {
const bytes = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk);
body = Buffer.concat([
body,
bytes.subarray(0, LIMIT - body.length),
]);
if (looksLikeM3u(body.toString('utf8')))
return {
state: 'active',
reason: 'available',
confirmedInactive: false,
};
if (body.length >= LIMIT) return sourceHealthUnknown('unknown');
}
return sourceHealthUnknown('invalid');
}
return sourceHealthUnknown('http');
} catch {
return sourceHealthUnknown(
control.signal?.aborted
? Date.now() >= payload.probe.deadlineAt
? 'timeout'
: 'cancelled'
: 'network'
);
} finally {
stream?.destroy();
control.dispose();
}
}
export function registerM3uSourceProbe() {
ipcMain.handle(M3U_SOURCE_PROBE, (event, payload) =>
probeM3uSource(payload, event.sender.id)
);
}
@@ -0,0 +1,34 @@
import { ipcMain } from 'electron';
import {
SOURCE_HEALTH_CANCEL,
SourceProbeContext,
} from '@iptvnator/shared/interfaces';
const requests = new Map<string, AbortController>();
export function sourceProbeControl(
owner: number,
context?: SourceProbeContext
) {
if (!context) return { signal: undefined, dispose: () => undefined };
const controller = new AbortController();
const key = `${owner}:${context.requestId}`;
const remaining = Math.min(
15000,
Math.max(0, context.deadlineAt - Date.now())
);
requests.set(key, controller);
const timer = setTimeout(() => controller.abort(), remaining);
if (!remaining) controller.abort();
return {
signal: controller.signal,
dispose: () => {
clearTimeout(timer);
if (requests.get(key) === controller) requests.delete(key);
},
};
}
export function registerSourceProbeCancellation() {
ipcMain.handle(SOURCE_HEALTH_CANCEL, (event, requestId: string) => {
requests.get(`${event.sender.id}:${requestId}`)?.abort();
});
}
@@ -1,3 +1,5 @@
import { sourceProbeControl } from './source-probe-control';
import type { SourceProbeContext } from '@iptvnator/shared/interfaces';
/**
* This module handles all Stalker portal related IPC communications
* between the frontend and the electron backend.
@@ -56,6 +58,7 @@ ipcMain.handle(
token?: string;
serialNumber?: string;
requestId?: string;
probe?: SourceProbeContext;
/**
* Set by endpoint discovery only. Its probes walk several candidate
* paths on one host and expect most of them to fail, so their
@@ -66,6 +69,10 @@ ipcMain.handle(
skipConnectionGuard?: boolean;
}
) => {
const probeControl = sourceProbeControl(
event.sender?.id ?? 0,
payload.probe
);
const startedAt = Date.now();
let debugRequest: Record<string, unknown> | undefined;
let requestUrlForLog = payload.url;
@@ -105,6 +112,7 @@ ipcMain.handle(
// Configure axios request
const config: AxiosRequestConfig = {
method: 'GET',
signal: probeControl.signal,
url: fullUrl,
headers,
timeout: requestTimeout,
@@ -266,6 +274,7 @@ ipcMain.handle(
};
}
} finally {
probeControl.dispose();
releaseGuardedHostRequest(guardToken);
}
}
@@ -162,6 +162,26 @@ describe('XtreamEvents session cancellation', () => {
}
);
it.each([500, 502, 503])('preserves rejected Axios HTTP %s in the health-probe error', async (status) => {
const error = { message: 'Request failed', response: { status } };
axiosMock.mockRejectedValueOnce(error);
axiosMock.isAxiosError.mockImplementation((value) => value === error);
await expect(registeredHandlers.get('XTREAM_REQUEST')?.(
{ sender: { id: 7 } },
{ url: 'https://example.com', params: {}, suppressErrorLog: true,
probe: { requestId: 'status', deadlineAt: Date.now() + 5000 } }
)).rejects.toThrow(`HTTP Error ${status}`);
});
it.each([401, 403])('preserves HTTP %s in the serialized health-probe error', async (status) => {
axiosMock.mockResolvedValueOnce({ status, statusText: 'refused', headers: {}, data: '' });
await expect(registeredHandlers.get('XTREAM_REQUEST')?.(
{ sender: { id: 7 } },
{ url: 'https://example.com', params: {}, suppressErrorLog: true,
probe: { requestId: 'status', deadlineAt: Date.now() + 5000 } }
)).rejects.toThrow(`HTTP Error ${status}`);
});
it('returns the provider HTTP error without enabling fallback', async () => {
axiosMock.mockResolvedValueOnce({
status: 403,
@@ -1,3 +1,5 @@
import { sourceProbeControl } from './source-probe-control';
import type { SourceProbeContext } from '@iptvnator/shared/interfaces';
/**
* This module handles all Xtream Codes API related IPC communications
* between the frontend and the electron backend.
@@ -65,11 +67,16 @@ ipcMain.handle(
url: string;
params: Record<string, string>;
requestId?: string;
probe?: SourceProbeContext;
sessionId?: string;
suppressErrorLog?: boolean;
connectionTest?: boolean;
}
) => {
const probeControl = sourceProbeControl(
event.sender?.id ?? 0,
payload.probe
);
const startedAt = Date.now();
const performanceCapture = createXtreamMainPerformanceCaptureForRequest(
payload.requestId
@@ -112,7 +119,7 @@ ipcMain.handle(
},
timeout: 30000, // 30 seconds timeout for Xtream API
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
signal: controller.signal,
signal: probeControl.signal ?? controller.signal,
};
if (performanceCapture) {
config.transformResponse =
@@ -144,6 +151,7 @@ ipcMain.handle(
// Check if response is successful
if (response.status >= 400) {
if (payload.probe) throw new Error(`HTTP Error ${response.status}`);
throw {
message: `HTTP Error: ${response.statusText}`,
status: response.status,
@@ -262,6 +270,13 @@ ipcMain.handle(
// Format error response
if (axios.isAxiosError(error)) {
if (payload.probe) {
if (typeof error.response?.status === 'number')
throw new Error(`HTTP Error ${error.response.status}`);
throw new Error(error.code === 'ERR_CANCELED'
? 'Xtream request cancelled'
: error.message || 'Xtream network request failed');
}
if (error.code === 'ERR_CANCELED') {
throw {
type: 'ERROR',
@@ -293,6 +308,7 @@ ipcMain.handle(
};
}
} finally {
probeControl.dispose();
releaseGuardedHostRequest(guardToken);
if (activeRequestKey) {
activeXtreamRequests.delete(activeRequestKey);
@@ -1,3 +1,4 @@
import { PassThrough } from 'node:stream';
import axios from 'axios';
import type { LookupAddress, LookupOptions } from 'node:dns';
import { Agent as HttpAgent } from 'node:http';
@@ -38,6 +39,45 @@ describe('requestWithValidatedRedirects', () => {
axiosMock.mockReset();
});
it('closes intermediate response streams before requesting the next hop', async () => {
const intermediate = new PassThrough();
const final = new PassThrough();
axiosMock.mockResolvedValueOnce({
status: 302,
headers: { location: '/final' },
data: intermediate,
});
axiosMock.mockImplementationOnce(async () => {
expect(intermediate.destroyed).toBe(true);
return { status: 200, headers: {}, data: final };
});
const response = await requestWithValidatedRedirects(
'https://example.com/start',
{ responseType: 'stream' },
{ resolveHostname: publicResolver }
);
expect(response.data).toBe(final);
expect(final.destroyed).toBe(false);
final.destroy();
});
it('closes a redirect stream even when its location is missing', async () => {
const body = new PassThrough();
axiosMock.mockResolvedValueOnce({
status: 302,
headers: {},
data: body,
});
await expect(
requestWithValidatedRedirects(
'https://example.com/start',
{ responseType: 'stream' },
{ resolveHostname: publicResolver }
)
).rejects.toThrow('location');
expect(body.destroyed).toBe(true);
});
it('rejects a redirect target that violates the URL policy', async () => {
axiosMock.mockResolvedValueOnce({
status: 302,
@@ -3,6 +3,7 @@ import axios, {
AxiosResponse,
RawAxiosRequestHeaders,
} from 'axios';
import { Readable } from 'node:stream';
import type { LookupAddress } from 'node:dns';
import { Agent as HttpAgent } from 'node:http';
import { Agent as HttpsAgent } from 'node:https';
@@ -215,6 +216,9 @@ export async function requestWithValidatedRedirects<T = unknown>(
return response;
}
// No consumer owns intermediate bodies, including redirects rejected below.
if (response.data instanceof Readable) response.data.destroy();
const location = response.headers?.location;
if (!location) {
throw new UnsafeUrlError(
+4
View File
@@ -1,3 +1,5 @@
import { registerM3uSourceProbe } from './app/events/m3u-source-probe';
import { registerSourceProbeCancellation } from './app/events/source-probe-control';
// Select persistence before eager imports (notably electron-conf) cache userData.
import './app/services/electron-profile-bootstrap';
import { app, BrowserWindow } from 'electron';
@@ -161,6 +163,8 @@ export default class Main {
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
registerM3uSourceProbe();
registerSourceProbeCancellation();
registerConnectivityGuardHandlers();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
@@ -108,6 +108,28 @@ describe('ElectronService', () => {
jest.restoreAllMocks();
});
it.each([401, 403])('preserves a health HTTP %s rejection without global error handling', async (status) => {
const error = new Error(`Error invoking remote method: Error: HTTP Error ${status}`);
electronBridge.xtreamRequest.mockRejectedValue(error);
const post = jest.spyOn(window, 'postMessage');
await expect(service.sendIpcEvent(XTREAM_REQUEST, {
url: 'https://provider.example', params: {},
probe: { requestId: 'health', deadlineAt: Date.now() + 5000 },
})).rejects.toBe(error);
expect(snackBar.open).not.toHaveBeenCalled();
expect(post).not.toHaveBeenCalled();
});
it('keeps health success request-local without broadcasting catalog responses', async () => {
const response = { payload: { user_info: { status: 'Active' } } };
electronBridge.xtreamRequest.mockResolvedValue(response);
const post = jest.spyOn(window, 'postMessage');
expect(await service.sendIpcEvent(XTREAM_REQUEST, {
url: 'https://provider.example', params: {},
probe: { requestId: 'health', deadlineAt: Date.now() + 5000 },
})).toEqual(response);
expect(post).not.toHaveBeenCalled();
});
it('preserves connection-test errors across the renderer bridge', async () => {
const response = {
connectionFailure: { kind: 'tls', canTryHttp: false },
@@ -20,6 +20,7 @@ import {
XTREAM_REQUEST,
XTREAM_RESPONSE,
XtreamCodeActions,
SourceProbeContext,
} from '@iptvnator/shared/interfaces';
import {
measureRendererPerformancePhase,
@@ -605,6 +606,7 @@ export class ElectronService extends DataService {
} */
private async forwardXtreamRequest(payload: {
probe?: SourceProbeContext;
connectionTest?: boolean;
url: string;
params: Record<string, string>;
@@ -626,7 +628,7 @@ export class ElectronService extends DataService {
requestId: context.requestId,
});
if (payload.connectionTest) return response;
if (payload.connectionTest || payload.probe) return response;
const result = {
type: XTREAM_RESPONSE,
@@ -636,6 +638,7 @@ export class ElectronService extends DataService {
window.postMessage(result);
return result;
} catch (error: unknown) {
if (payload.probe) throw error;
const action = payload.params?.action;
const isSilentAction =
payload.suppressErrorLog === true ||
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "جارٍ تحميل المصادر والتحقق من دليل البرامج. قد تستغرق المكتبات الكبيرة وقتًا أطول.",
"FAILED": "تعذر تحميل المصادر",
"FAILED_DETAIL": "أعد محاولة قراءة المصادر المحفوظة. لم تُحذف بياناتك."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "كنحمّلو المصادر وكنراجعو دليل البرامج. المكتبات الكبيرة تقدر تاخد وقت أكثر.",
"FAILED": "ما قدرناش نحمّلو المصادر",
"FAILED_DETAIL": "عاود جرّب تقرا المصادر المحفوظة. البيانات ديالك ما تمسحوش."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Загружаем крыніцы і правяраем тэлепраграму. Вялікая бібліятэка можа патрабаваць больш часу.",
"FAILED": "Не ўдалося загрузіць крыніцы",
"FAILED_DETAIL": "Паўтарыце чытанне захаваных крыніц. Вашы даныя не выдалены."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Quellen werden geladen und der Programmführer geprüft. Große Bibliotheken können etwas länger dauern.",
"FAILED": "Quellen konnten nicht geladen werden",
"FAILED_DETAIL": "Versuche erneut, die gespeicherten Quellen zu lesen. Deine Daten wurden nicht gelöscht."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Φόρτωση πηγών και έλεγχος του οδηγού προγράμματος. Οι μεγάλες βιβλιοθήκες μπορεί να χρειαστούν περισσότερο χρόνο.",
"FAILED": "Δεν ήταν δυνατή η φόρτωση των πηγών",
"FAILED_DETAIL": "Δοκιμάστε ξανά την ανάγνωση των αποθηκευμένων πηγών. Τα δεδομένα σας δεν διαγράφηκαν."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Loading your sources and checking the programme guide. Large libraries may take a little longer.",
"FAILED": "Your sources could not be loaded",
"FAILED_DETAIL": "Retry to read your saved sources again. Your data has not been deleted."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Cargando fuentes y comprobando la guía de programación. Las bibliotecas grandes pueden tardar un poco más.",
"FAILED": "No se pudieron cargar tus fuentes",
"FAILED_DETAIL": "Vuelve a intentar leer las fuentes guardadas. Tus datos no se han eliminado."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Chargement des sources et vérification du guide TV. Les grandes bibliothèques peuvent prendre plus de temps.",
"FAILED": "Impossible de charger vos sources",
"FAILED_DETAIL": "Réessayez de lire les sources enregistrées. Vos données n’ont pas été supprimées."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Források betöltése és a műsorújság ellenőrzése. Nagy könyvtárak esetén ez tovább tarthat.",
"FAILED": "Nem sikerült betölteni a forrásokat",
"FAILED_DETAIL": "Próbáld újra beolvasni a mentett forrásokat. Az adataid nem lettek törölve."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Caricamento delle sorgenti e verifica della guida TV. Le librerie grandi possono richiedere più tempo.",
"FAILED": "Impossibile caricare le sorgenti",
"FAILED_DETAIL": "Riprova a leggere le sorgenti salvate. I tuoi dati non sono stati eliminati."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "ソースを読み込み、番組表を確認しています。大きなライブラリでは時間がかかる場合があります。",
"FAILED": "ソースを読み込めませんでした",
"FAILED_DETAIL": "保存したソースの読み込みを再試行してください。データは削除されていません。"
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "소스를 불러오고 편성표를 확인하고 있습니다. 라이브러리가 크면 시간이 더 걸릴 수 있습니다.",
"FAILED": "소스를 불러오지 못했습니다",
"FAILED_DETAIL": "저장된 소스를 다시 읽어 보세요. 데이터는 삭제되지 않았습니다."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Bronnen laden en de programmagids controleren. Grote bibliotheken kunnen wat meer tijd kosten.",
"FAILED": "Je bronnen konden niet worden geladen",
"FAILED_DETAIL": "Probeer je opgeslagen bronnen opnieuw te lezen. Je gegevens zijn niet verwijderd."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Wczytywanie źródeł i sprawdzanie programu TV. Duże biblioteki mogą wymagać więcej czasu.",
"FAILED": "Nie udało się wczytać źródeł",
"FAILED_DETAIL": "Spróbuj ponownie odczytać zapisane źródła. Twoje dane nie zostały usunięte."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "A carregar fontes e verificar o guia de programação. Bibliotecas grandes podem demorar mais.",
"FAILED": "Não foi possível carregar as fontes",
"FAILED_DETAIL": "Tente ler novamente as fontes guardadas. Os seus dados não foram eliminados."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Загружаем источники и проверяем телепрограмму. Большая библиотека может потребовать больше времени.",
"FAILED": "Не удалось загрузить источники",
"FAILED_DETAIL": "Повторите чтение сохранённых источников. Ваши данные не удалены."
},
"SOURCE_HEALTH": {
"RECHECK": "Проверить снова",
"M3U_SCOPE": "Ссылка на плейлист, не отдельные каналы",
"REASON": {
"available": "Доступен",
"disabled": "Аккаунт отключён",
"expired": "Подписка истекла",
"auth": "Доступ отклонён; статус аккаунта не подтверждён",
"http": "Сервер вернул ошибку",
"timeout": "Истекло время проверки",
"network": "Не удалось подключиться",
"paused": "Проверки подключения приостановлены",
"invalid": "Неожиданный ответ",
"unknown": "Не проверен",
"cancelled": "Проверка отменена",
"checking": "Проверяется…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "Kaynaklar yükleniyor ve program rehberi kontrol ediliyor. Büyük kitaplıklar biraz daha uzun sürebilir.",
"FAILED": "Kaynaklarınız yüklenemedi",
"FAILED_DETAIL": "Kayıtlı kaynaklarınızı okumayı yeniden deneyin. Verileriniz silinmedi."
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "正在加载来源并检查节目指南。较大的媒体库可能需要更长时间。",
"FAILED": "无法加载来源",
"FAILED_DETAIL": "请重试读取已保存的来源。您的数据未被删除。"
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
+18
View File
@@ -1789,5 +1789,23 @@
"PREPARING_DETAIL": "正在載入來源並檢查節目指南。較大的媒體庫可能需要更長時間。",
"FAILED": "無法載入來源",
"FAILED_DETAIL": "請重試讀取已儲存的來源。您的資料未被刪除。"
},
"SOURCE_HEALTH": {
"RECHECK": "Check again",
"M3U_SCOPE": "Playlist URL, not individual channels",
"REASON": {
"available": "Available",
"disabled": "Account disabled",
"expired": "Subscription expired",
"auth": "Access denied; account status unconfirmed",
"http": "Server returned an error",
"timeout": "Check timed out",
"network": "Could not connect",
"paused": "Connection checks paused",
"invalid": "Unexpected response",
"unknown": "Not verified",
"cancelled": "Check cancelled",
"checking": "Checking…"
}
}
}
@@ -673,3 +673,11 @@ A visual change is not done until:
3. Selection and progress states match existing IPTVnator patterns.
4. Scroll behavior is correct.
5. The result was checked in the running app for layout-sensitive work.
### Network source indicators (Electron)
The switcher and source rows use `SourceHealthIndicatorComponent`: green means
available, orange expired, red disabled/access refused, and neutral means
checking or unverified. Accessible tooltip text distinguishes refusal from a
confirmed disabled account and includes the check time. M3U indicators describe
the source URL only. Check again is a separate action, not a playlist refresh.
+35
View File
@@ -1681,3 +1681,38 @@ overrides and bulk guides. A delayed startup import is
started only if its source still belongs to the reconciled configuration; its
completion observer is installed after settings initialization. Provider EPG
continues through its existing APIs. Playlist refresh is not EPG cache cleanup.
## Desktop source health
The source switcher and Sources rows share `SourceHealthService` in portal
shared data access. Electron checks Xtream account info, Stalker account/profile
facts through the existing session, and the first 64 KiB of M3U URL responses.
An M3U success describes the playlist URL, not every channel. Local files and
text imports have no network indicator. PWA retains its existing Xtream path.
Checks are demand-driven, never a startup readiness barrier: four at a time,
two per origin, shared between surfaces. Known results remain visible during
refresh (60-second evidence TTL, 15-second uncertain-result TTL). Background
requests have a five-second aggregate deadline; explicit checks have fifteen
seconds, including a handoff from a running background check. M3U streams are
destroyed on completion, limit, error or cancellation,
including when a provider ignores Range. Every intermediate redirect stream is
closed before the next hop is validated or requested. Header/TLS and validated redirect
policies are the same as playlist downloads. No playlist contents are replaced.
Indicator effects do not track coordinator cache reads, so publishing a result
does not restart or cancel the request. Xtream health replies stay request-local
in the Electron renderer adapter: failures reject with their serialized message,
and successful checks do not broadcast catalog response events. When newer account evidence arrives
during a probe, both the cache and the waiting caller retain that newer result.
Account-disabled/expired evidence is separate from authorization errors,
network failures and connectivity-guard pauses. Only explicit account evidence
is eligible for automatic cleanup selection. Statuses are session-only;
committed SQLite connection edits and deletions publish inventory events that
invalidate pending evidence, including deferred explicit retries. Retry now explicitly
resets the portal guard; ordinary checks do not. Credentials and response bodies
are never included in indicator text.
Explicit source-health checks retain their deadline while queued, including
background jobs promoted by Retry. Expired queued checks resolve without opening
a transport; cancellation and admission clear the queue timer.
+12
View File
@@ -1750,3 +1750,15 @@ Covered scenarios include:
- Inline and external episode handoffs carry resolved season/episode metadata
- Dashboard activity classifies `is_series` VOD as series and resolves its
saved episode position
### Desktop source health checks
`StalkerSourceHealthService` checks simple portals with `get_main_info` through
`executeStalkerRequest`, without the optional repair dependency. Full portals
reuse `StalkerSessionService.ensureToken` and the existing profile API. Health
checks never launch endpoint discovery or force renewal of a warm session.
Cold authentication retains the shared session slot and its ordinary transport
lifetime. The health consumer bounds only its own wait; its deadline and closing
a status surface never cancel or shorten authentication shared with playback.
Profile/account reads have request-owned cancellation. Outer profile challenge
statuses are not subscription statuses. Missing account dates stay unknown.
@@ -25,9 +25,7 @@
(click)="onRefreshClick($event)"
(mousedown)="$event.stopPropagation()"
[matTooltip]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
[attr.aria-label]="
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
"
[attr.aria-label]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
>
<mat-icon
class="trigger-refresh-icon"
@@ -72,26 +70,26 @@
</div>
@if (showSearchField()) {
<div class="search-container" (click)="$event.stopPropagation()">
<mat-icon class="search-icon">search</mat-icon>
<input
#searchInput
type="text"
class="search-input"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
[ngModel]="searchQuery()"
(ngModelChange)="setSearchQuery($event)"
/>
@if (searchQuery()) {
<button
mat-icon-button
class="clear-btn"
(click)="setSearchQuery('')"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
<div class="search-container" (click)="$event.stopPropagation()">
<mat-icon class="search-icon">search</mat-icon>
<input
#searchInput
type="text"
class="search-input"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
[ngModel]="searchQuery()"
(ngModelChange)="setSearchQuery($event)"
/>
@if (searchQuery()) {
<button
mat-icon-button
class="clear-btn"
(click)="setSearchQuery('')"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
}
@if (showTypeFilters()) {
@@ -146,7 +144,12 @@
<mat-icon class="playlist-type-icon">
{{ getPlaylistIcon(playlist) }}
</mat-icon>
@if (playlist.serverUrl) {
@if (runtime.supportsSourceHealth) {
<app-source-health-indicator
[playlist]="playlist"
[enabled]="isMenuOpen()"
/>
} @else if (playlist.serverUrl) {
<span
class="status-dot"
[class]="getStatusClass(playlist._id)"
@@ -256,8 +259,17 @@
<span>{{ 'WORKSPACE.SHELL.ACCOUNT_INFO' | translate }}</span>
</button>
}
@if (runtime.supportsSourceHealth && sourceHealthType(playlist)) {
<button mat-menu-item (click)="recheckSource(playlist)">
<mat-icon>network_check</mat-icon
><span>{{ 'SOURCE_HEALTH.RECHECK' | translate }}</span>
</button>
}
@if (canRefreshPlaylist(playlist)) {
<button mat-menu-item (click)="refreshPlaylistFor(playlist, $event)">
<button
mat-menu-item
(click)="refreshPlaylistFor(playlist, $event)"
>
<mat-icon>sync</mat-icon>
<span>{{
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
@@ -1,3 +1,8 @@
import { Injector } from '@angular/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { sourceHealthType } from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from '../source-health/source-health-indicator.component';
import { DatePipe, DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
@@ -57,6 +62,7 @@ const DEFAULT_PLAYLIST_TYPE_FILTERS: Record<PlaylistFilterType, boolean> = {
styleUrls: ['./playlist-switcher.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
SourceHealthIndicatorComponent,
DatePipe,
FormsModule,
MatDivider,
@@ -87,6 +93,13 @@ export class PlaylistSwitcherComponent {
{ initialValue: null }
);
readonly runtime = inject(RuntimeCapabilitiesService);
private readonly healthInjector = inject(Injector);
readonly sourceHealthType = sourceHealthType;
recheckSource(playlist: PlaylistMeta): void {
void this.healthInjector.get(SourceHealthService).recheck(playlist);
}
readonly currentTitle = input.required<string>();
readonly subtitle = input<string>('');
readonly showPlaylistInfo = input(false);
@@ -210,7 +223,8 @@ export class PlaylistSwitcherComponent {
if (this.showSearchField()) {
this.focusSearchField();
}
void this.checkPortalStatuses(this.playlists());
if (!this.runtime.supportsSourceHealth)
void this.checkPortalStatuses(this.playlists());
}
onMenuClosed(): void {
@@ -11,6 +11,12 @@
<mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon>
}
<div class="playlist-icon">
@if (runtime.supportsSourceHealth) {
<app-source-health-indicator
[playlist]="item"
[enabled]="!isBusy()"
/>
}
@if (item.url) {
<div class="icon-container">
<mat-icon
@@ -58,12 +64,14 @@
[matTooltip]="'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate"
>public</mat-icon
>
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="'Portal status: ' + portalStatus"
[attr.aria-label]="'Portal status: ' + portalStatus"
></span>
@if (!runtime.supportsSourceHealth) {
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="'Portal status: ' + portalStatus"
[attr.aria-label]="'Portal status: ' + portalStatus"
></span>
}
</div>
}
@if (item.macAddress) {
@@ -83,7 +91,10 @@
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date: undefined : undefined : currentLocale() }}
{{
item.importDate
| date: undefined : undefined : currentLocale()
}}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
@@ -113,7 +124,10 @@
}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date: undefined : undefined : currentLocale() }}
{{
item.importDate
| date: undefined : undefined : currentLocale()
}}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
@@ -152,6 +166,17 @@
@if (showActions()) {
<div class="action-buttons">
@if (runtime.supportsSourceHealth && sourceHealthType(item)) {
<button
mat-icon-button
[disabled]="isBusy()"
(click)="$event.stopPropagation(); recheckSource()"
[matTooltip]="'SOURCE_HEALTH.RECHECK' | translate"
[attr.aria-label]="'SOURCE_HEALTH.RECHECK' | translate"
>
<mat-icon>network_check</mat-icon>
</button>
}
@if (canCancelBusyAction()) {
<button
mat-icon-button
@@ -1,3 +1,7 @@
import { Injector } from '@angular/core';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { sourceHealthType } from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from '../../source-health/source-health-indicator.component';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { DatePipe } from '@angular/common';
import {
@@ -30,6 +34,7 @@ import { PlaylistMeta } from '@iptvnator/shared/interfaces';
templateUrl: './playlist-item.component.html',
styleUrls: ['./playlist-item.component.scss'],
imports: [
SourceHealthIndicatorComponent,
DatePipe,
DragDropModule,
MatIconButton,
@@ -60,7 +65,7 @@ export class PlaylistItemComponent implements OnInit {
portalStatus: PortalStatus = 'unavailable';
private readonly portalStatusService = inject(PortalStatusService);
private readonly runtime = inject(RuntimeCapabilitiesService);
readonly runtime = inject(RuntimeCapabilitiesService);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -77,17 +82,24 @@ export class PlaylistItemComponent implements OnInit {
);
});
private readonly healthInjector = inject(Injector);
readonly sourceHealthType = sourceHealthType;
recheckSource(): void {
void this.healthInjector.get(SourceHealthService).recheck(this.item);
}
async ngOnInit() {
await this.checkPortalStatus();
if (!this.runtime.supportsSourceHealth) await this.checkPortalStatus();
}
private async checkPortalStatus() {
if (this.item.serverUrl && this.item.username && this.item.password) {
this.portalStatus = await this.portalStatusService.checkPortalStatus(
this.item.serverUrl,
this.item.username,
this.item.password
);
this.portalStatus =
await this.portalStatusService.checkPortalStatus(
this.item.serverUrl,
this.item.username,
this.item.password
);
}
}
@@ -0,0 +1,65 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { SourceHealthSnapshot } from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from './source-health-indicator.component';
describe('SourceHealthIndicatorComponent', () => {
it.each(['en', 'zhtw'])(
'updates the dot and localized time without restarting its request (%s)',
(language) => {
const snapshot = signal<SourceHealthSnapshot | undefined>(
undefined
);
const check = jest.fn().mockImplementation(() => {
snapshot(); // The real coordinator reads its signal-backed cache.
return Promise.resolve();
});
TestBed.configureTestingModule({
imports: [
SourceHealthIndicatorComponent,
TranslateModule.forRoot(),
NoopAnimationsModule,
],
providers: [
{
provide: RuntimeCapabilitiesService,
useValue: { supportsSourceHealth: true },
},
{
provide: SourceHealthService,
useValue: { get: snapshot, check },
},
],
});
TestBed.inject(TranslateService).use(language);
const fixture = TestBed.createComponent(
SourceHealthIndicatorComponent
);
fixture.componentRef.setInput('playlist', {
_id: 'a',
url: 'https://source.test/list',
});
fixture.detectChanges();
const requestSignal = check.mock.calls[0][1].signal as AbortSignal;
snapshot.set({
state: 'active',
reason: 'available',
confirmedInactive: false,
checkedAt: Date.now(),
});
fixture.detectChanges();
expect(fixture.componentInstance.description()).toContain(' · ');
expect(check).toHaveBeenCalledTimes(1);
expect(requestSignal.aborted).toBe(false);
expect(
fixture.nativeElement.querySelector('.health-dot').dataset.state
).toBe('active');
fixture.destroy();
expect(requestSignal.aborted).toBe(true);
}
);
});
@@ -0,0 +1,111 @@
import {
Component,
Injector,
computed,
effect,
inject,
input,
untracked,
} from '@angular/core';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { toSignal } from '@angular/core/rxjs-interop';
import { startWith } from 'rxjs';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces';
@Component({
selector: 'app-source-health-indicator',
imports: [MatTooltip],
template: `@if (supported()) {
<span
class="health-dot"
[attr.data-state]="snapshot()?.state ?? 'unknown'"
[matTooltip]="description()"
[attr.aria-label]="description()"
role="img"
></span>
}`,
styles: [
`
:host {
display: inline-flex;
vertical-align: middle;
}
.health-dot {
display: block;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--mat-sys-on-surface-variant);
border: 2px solid var(--mat-sys-surface);
}
[data-state='active'] {
background: #4caf50;
}
[data-state='expired'] {
background: #ff9800;
}
[data-state='inactive'] {
background: #f44336;
}
[data-state='checking'] {
opacity: 0.45;
}
`,
],
})
export class SourceHealthIndicatorComponent {
readonly playlist = input.required<PlaylistMeta>();
readonly enabled = input(true);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly injector = inject(Injector);
private readonly translate = inject(TranslateService);
private readonly language = toSignal(
this.translate.onLangChange.pipe(startWith(null))
);
readonly supported = computed(
() =>
this.runtime.supportsSourceHealth &&
!!sourceHealthType(this.playlist())
);
readonly snapshot = computed(() =>
this.supported()
? this.injector.get(SourceHealthService).get(this.playlist())
: undefined
);
readonly description = computed(() => {
this.language();
const snapshot = this.snapshot();
const reason =
snapshot?.state === 'checking'
? 'checking'
: (snapshot?.reason ?? 'unknown');
const label = this.translate.instant(`SOURCE_HEALTH.REASON.${reason}`);
const time = snapshot?.checkedAt
? new Date(snapshot.checkedAt).toLocaleTimeString(
normalizeDateLocale(this.translate.currentLang || 'en')
)
: '';
const scope =
sourceHealthType(this.playlist()) === 'm3u'
? this.translate.instant('SOURCE_HEALTH.M3U_SCOPE')
: '';
return [label, scope, time].filter(Boolean).join(' · ');
});
constructor() {
effect((onCleanup) => {
const p = this.playlist();
if (!this.enabled() || !this.supported()) return;
const controller = new AbortController();
untracked(() => {
void this.injector
.get(SourceHealthService)
.check(p, { signal: controller.signal });
});
onCleanup(() => controller.abort());
});
}
}
@@ -2,3 +2,5 @@ export * from './lib/collection';
export * from './lib/downloads';
export * from './lib/multi-source';
export * from './lib/live-channel-playback-queue';
export * from './lib/source-health.service';
@@ -0,0 +1,106 @@
import { Injectable, Injector, inject } from '@angular/core';
import {
DataService,
PortalStatusService,
SettingsStore,
withSourceProbe,
} from '@iptvnator/services';
import { StalkerSourceHealthService } from '@iptvnator/portal/stalker/data-access';
import {
PlaylistMeta,
SourceProbeContext,
SourceHealthResult,
sourceHealthUnknown,
sourceHealthType,
accountHealth,
resolveXtreamPortalExpiration,
XtreamPortalStatusResponseLike,
} from '@iptvnator/shared/interfaces';
@Injectable({ providedIn: 'root' })
export class SourceHealthProbesService {
private readonly injector = inject(Injector);
private readonly data = inject(DataService);
async check(
p: PlaylistMeta,
probe: SourceProbeContext,
signal?: AbortSignal
): Promise<SourceHealthResult> {
switch (sourceHealthType(p)) {
case 'stalker':
return this.injector
.get(StalkerSourceHealthService)
.check(p, probe, signal);
case 'm3u':
return window.electron.probeM3uSource({
url: p.url!,
userAgent: p.userAgent,
trustedInsecureTlsHosts:
this.injector
.get(SettingsStore)
.trustedInsecureTlsHosts?.() ?? [],
probe,
});
case 'xtream': {
const data = withSourceProbe(this.data, probe, signal);
let failure: unknown;
for (const action of [
'get_account_info',
null,
'get_profile',
]) {
try {
const response = await data.sendIpcEvent<{
payload?: XtreamPortalStatusResponseLike;
}>('XTREAM_REQUEST', {
url: p.serverUrl,
params: {
username: p.username,
password: p.password,
...(action ? { action } : {}),
},
suppressErrorLog: true,
});
if (!response?.payload?.user_info) continue;
this.injector
.get(PortalStatusService)
.rememberXtreamResponse(
p.serverUrl!,
p.username!,
p.password!,
response.payload
);
const info = response.payload.user_info;
if (
info.auth === false ||
info.auth === 0 ||
info.auth === '0'
) {
const explicit = accountHealth(info.status);
return explicit.confirmedInactive
? explicit
: {
...sourceHealthUnknown('auth'),
state: 'inactive',
};
}
return accountHealth(
info.status,
resolveXtreamPortalExpiration(response.payload) ??
undefined,
info.auth === true ||
info.auth === 1 ||
info.auth === '1'
);
} catch (error) {
failure = error;
}
}
if (failure) throw failure;
return sourceHealthUnknown('invalid');
}
default:
return sourceHealthUnknown();
}
}
}
@@ -0,0 +1,221 @@
import { TestBed } from '@angular/core/testing';
import {
RuntimeCapabilitiesService,
SourceHealthEvidenceService,
} from '@iptvnator/services';
import { PlaylistMeta, SourceHealthResult } from '@iptvnator/shared/interfaces';
import { SourceHealthService } from './source-health.service';
import { SourceHealthProbesService } from './source-health-probes.service';
const active: SourceHealthResult = {
state: 'active',
reason: 'available',
confirmedInactive: false,
};
const playlist = (id: string, host = id): PlaylistMeta => ({
_id: id,
url: `https://${host}.test/list`,
});
describe('SourceHealthService', () => {
let service: SourceHealthService;
let probe: jest.Mock;
beforeEach(() => {
probe = jest.fn().mockResolvedValue(active);
TestBed.configureTestingModule({
providers: [
SourceHealthService,
SourceHealthEvidenceService,
{
provide: RuntimeCapabilitiesService,
useValue: { supportsSourceHealth: true },
},
{
provide: SourceHealthProbesService,
useValue: { check: probe },
},
],
});
window.electron = {
cancelSourceProbe: jest.fn().mockResolvedValue(undefined),
} as unknown as typeof window.electron;
service = TestBed.inject(SourceHealthService);
});
it('deduplicates both surfaces and serves the cached result', async () => {
await Promise.all([
service.check(playlist('a')),
service.check(playlist('a')),
]);
await service.check(playlist('a'));
expect(probe).toHaveBeenCalledTimes(1);
});
it('limits concurrent probes and updates fast sources independently', async () => {
const resolve: Array<(r: SourceHealthResult) => void> = [];
probe.mockImplementation(() => new Promise((r) => resolve.push(r)));
const checks = ['a', 'b', 'c', 'd', 'e'].map((id) =>
service.check(playlist(id))
);
expect(probe).toHaveBeenCalledTimes(4);
resolve[1](active);
await checks[1];
expect(service.get(playlist('b'))?.state).toBe('active');
expect(service.get(playlist('a'))?.state).toBe('checking');
expect(probe).toHaveBeenCalledTimes(5);
resolve.forEach((r) => r(active));
await Promise.all(checks);
});
it('limits same-origin concurrency to two', async () => {
probe.mockImplementation(() => new Promise(() => undefined));
for (const id of ['a', 'b', 'c'])
void service.check({ ...playlist(id, 'shared'), userAgent: id });
expect(probe).toHaveBeenCalledTimes(2);
['a', 'b', 'c'].forEach((id) => service.invalidate(id));
await Promise.resolve();
});
it.each([false, true])(
'bounds queued explicit checks (promoted: %s)',
async (promote) => {
jest.useFakeTimers();
try {
probe.mockImplementation(() => new Promise(() => undefined));
const blockers = ['a', 'b', 'c', 'd'].map((id) =>
service.check(playlist(id), { fresh: true })
);
const source = playlist('queued');
const background = promote ? service.check(source) : undefined;
const explicit = service.check(source, { fresh: true });
expect(probe).toHaveBeenCalledTimes(4);
await jest.advanceTimersByTimeAsync(15000);
expect(await explicit).toMatchObject({ reason: 'timeout' });
expect(probe).toHaveBeenCalledTimes(4);
await Promise.all([...blockers, background]);
expect(jest.getTimerCount()).toBe(0);
} finally {
jest.useRealTimers();
}
}
);
it('does not cancel another consumer when a menu closes', async () => {
let resolve!: (r: SourceHealthResult) => void;
probe.mockImplementation(
() =>
new Promise((r) => {
resolve = r;
})
);
const controller = new AbortController();
const a = service.check(playlist('a'), { signal: controller.signal });
const b = service.check(playlist('a'));
controller.abort();
expect(window.electron.cancelSourceProbe).not.toHaveBeenCalled();
resolve(active);
await Promise.all([a, b]);
});
it('invalidates a pending response after deletion', async () => {
let resolve!: (r: SourceHealthResult) => void;
probe.mockImplementation(
() =>
new Promise((r) => {
resolve = r;
})
);
const check = service.check(playlist('a'));
service.invalidate('a');
resolve(active);
await check;
expect(service.get(playlist('a'))).toBeUndefined();
});
it.each(['edit', 'delete'])(
'retires deferred retries after a committed %s',
async (operation) => {
probe.mockImplementation(() => new Promise(() => undefined));
const source = playlist('a');
const pending = service.check(source);
const retry = service.check(source, { fresh: true });
TestBed.inject(SourceHealthEvidenceService).connections.next({
id: source._id,
...(operation === 'edit'
? { playlist: { ...source, url: 'https://new.test/list' } }
: {}),
});
await Promise.all([pending, retry]);
expect(probe).toHaveBeenCalledTimes(1);
expect(service.get(source)).toBeUndefined();
expect(window.electron.cancelSourceProbe).toHaveBeenCalled();
}
);
it.each([0, 10])(
'preserves newer evidence published %i ms after a probe starts',
async (elapsed) => {
jest.useFakeTimers();
try {
let resolve!: (result: SourceHealthResult) => void;
probe.mockImplementation(
() =>
new Promise((r) => {
resolve = r;
})
);
const source = playlist('a');
const pending = service.check(source);
jest.advanceTimersByTime(elapsed);
TestBed.inject(SourceHealthEvidenceService).results.next({
playlist: source,
result: active,
});
resolve({
state: 'expired',
reason: 'expired',
confirmedInactive: true,
});
expect(await pending).toMatchObject(active);
expect(service.get(source)).toMatchObject(active);
} finally {
jest.useRealTimers();
}
}
);
it('includes the background handoff in the explicit fifteen-second budget', async () => {
jest.useFakeTimers();
try {
probe.mockImplementation(() => new Promise(() => undefined));
const source = playlist('a');
const start = Date.now();
const background = service.check(source);
const explicit = service.check(source, { fresh: true });
await jest.advanceTimersByTimeAsync(5000);
await background;
expect(probe.mock.calls[1][1].deadlineAt).toBe(start + 15000);
await jest.advanceTimersByTimeAsync(10000);
expect(await explicit).toMatchObject({ reason: 'timeout' });
} finally {
jest.useRealTimers();
}
});
it('bounds a stalled probe and cancels its transport at the total deadline', async () => {
jest.useFakeTimers();
try {
probe.mockImplementation(() => new Promise(() => undefined));
const check = service.check(playlist('slow'));
jest.advanceTimersByTime(5000);
expect(await check).toMatchObject({
reason: 'timeout',
confirmedInactive: false,
});
expect(probe.mock.calls[0][2].aborted).toBe(true);
expect(window.electron.cancelSourceProbe).toHaveBeenCalledWith(
probe.mock.calls[0][1].requestId
);
} finally {
jest.useRealTimers();
}
});
it('uses a longer deadline for explicit checks and bypasses cached results', async () => {
await service.check(playlist('a'));
await service.check(playlist('a'), { fresh: true });
expect(probe).toHaveBeenCalledTimes(2);
expect(probe.mock.calls[1][1].deadlineAt - Date.now()).toBeGreaterThan(
14000
);
});
});
@@ -0,0 +1,305 @@
import { Injectable, inject, signal } from '@angular/core';
import {
SourceHealthEvidenceService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
PlaylistMeta,
SourceHealthSnapshot,
SourceHealthResult,
sourceHealthType,
sourceHealthKey,
sourceHealthUnknown,
sourceHealthError,
} from '@iptvnator/shared/interfaces';
import { SourceHealthProbesService } from './source-health-probes.service';
interface Job {
key: string;
playlist: PlaylistMeta;
origin: string;
explicit: boolean;
requestId: string;
deadlineAt?: number;
queueTimer?: ReturnType<typeof setTimeout>;
controller: AbortController;
users: Set<symbol>;
running: boolean;
promise: Promise<SourceHealthSnapshot>;
resolve: (value: SourceHealthSnapshot) => void;
}
@Injectable({ providedIn: 'root' })
export class SourceHealthService {
private readonly probes = inject(SourceHealthProbesService);
private readonly runtime = inject(RuntimeCapabilitiesService);
readonly snapshots = signal<ReadonlyMap<string, SourceHealthSnapshot>>(
new Map()
);
private readonly jobs = new Map<string, Job>();
private readonly identities = new Map<string, string>();
private readonly origins = new Map<string, number>();
private active = 0;
constructor() {
const evidence = inject(SourceHealthEvidenceService);
evidence.connections.subscribe(({ id, playlist }) => {
if (!id) {
[...this.identities.keys()].forEach((key) =>
this.invalidate(key)
);
} else if (
!playlist ||
this.identities.get(id) !== sourceHealthKey(playlist)
) {
this.invalidate(id);
}
});
evidence.results.subscribe(({ playlist, result }) => {
const key = sourceHealthKey(playlist);
if (![...this.identities.values()].includes(key)) return;
this.publish(key, { ...result, checkedAt: Date.now() });
});
}
async recheck(
p: PlaylistMeta,
signal?: AbortSignal
): Promise<SourceHealthSnapshot> {
if (signal?.aborted)
return { ...sourceHealthUnknown('cancelled'), checkedAt: 0 };
if (sourceHealthType(p) !== 'm3u')
await window.electron.resetHostConnectivityGuard(
p.portalUrl || p.serverUrl!
);
return this.check(p, { fresh: true, signal });
}
get(p: PlaylistMeta): SourceHealthSnapshot | undefined {
return this.snapshots().get(sourceHealthKey(p));
}
invalidate(id: string): void {
const key = this.identities.get(id);
this.identities.delete(id);
if (!key || [...this.identities.values()].includes(key)) return;
const job = this.jobs.get(key);
if (job) {
job.users.clear();
this.cancel(job);
}
this.snapshots.update((map) => {
const next = new Map(map);
next.delete(key);
return next;
});
}
check(
p: PlaylistMeta,
options: {
fresh?: boolean;
signal?: AbortSignal;
deadlineAt?: number;
} = {}
): Promise<SourceHealthSnapshot> {
if (
!this.runtime.supportsSourceHealth ||
!sourceHealthType(p) ||
options.signal?.aborted
)
return Promise.resolve({ ...sourceHealthUnknown(), checkedAt: 0 });
const key = sourceHealthKey(p);
if (this.identities.has(p._id) && this.identities.get(p._id) !== key)
this.invalidate(p._id);
this.identities.set(p._id, key);
const cached = this.snapshots().get(key);
const ttl =
cached?.state === 'active' || cached?.confirmedInactive
? 60000
: 15000;
if (!options.fresh && cached && Date.now() - cached.checkedAt < ttl)
return Promise.resolve(cached);
let job = this.jobs.get(key);
if (options.fresh && job?.running && !job.explicit) {
const deadlineAt = options.deadlineAt ?? Date.now() + 15000;
return job.promise.then(() =>
this.identities.get(p._id) === key
? this.check(p, { ...options, deadlineAt })
: { ...sourceHealthUnknown('cancelled'), checkedAt: 0 }
);
}
if (!job) {
let resolve!: Job['resolve'];
const promise = new Promise<SourceHealthSnapshot>((r) => {
resolve = r;
});
job = {
key,
playlist: { ...p },
origin: new URL(p.portalUrl || p.serverUrl || p.url!).origin,
explicit: !!options.fresh,
users: new Set(),
running: false,
requestId: crypto.randomUUID(),
deadlineAt: options.deadlineAt,
controller: new AbortController(),
promise,
resolve,
};
this.jobs.set(key, job);
if (!cached)
this.publish(key, {
state: 'checking',
reason: 'unknown',
confirmedInactive: false,
checkedAt: 0,
});
}
if (options.fresh && !job.running) {
job.explicit = true;
job.deadlineAt ??= options.deadlineAt ?? Date.now() + 15000;
this.boundQueueWait(job);
}
const user = Symbol();
job.users.add(user);
const ownedJob = job;
const cancel = () => {
ownedJob.users.delete(user);
if (!ownedJob.users.size) this.cancel(ownedJob);
};
options.signal?.addEventListener('abort', cancel, { once: true });
this.drain();
return job.promise.finally(() =>
options.signal?.removeEventListener('abort', cancel)
);
}
private boundQueueWait(job: Job): void {
if (job.queueTimer !== undefined) return;
const queuedAt = Date.now();
const initial = this.snapshots().get(job.key);
job.queueTimer = setTimeout(
() => {
if (job.running || this.jobs.get(job.key) !== job) return;
this.jobs.delete(job.key);
const previous = this.snapshots().get(job.key);
const snapshot =
previous && previous !== initial
? previous
: {
...sourceHealthUnknown('timeout'),
checkedAt: Date.now(),
};
if (
job.users.size &&
[...this.identities.values()].includes(job.key)
)
this.publish(job.key, snapshot);
job.resolve(snapshot);
},
Math.max(0, job.deadlineAt! - queuedAt)
);
}
private cancel(job: Job): void {
if (this.jobs.get(job.key) !== job) return;
clearTimeout(job.queueTimer);
job.controller.abort();
this.jobs.delete(job.key);
if (job.running) void window.electron.cancelSourceProbe(job.requestId);
else {
this.jobs.delete(job.key);
job.resolve({ ...sourceHealthUnknown('cancelled'), checkedAt: 0 });
}
}
private drain(): void {
const queue = [...this.jobs.values()]
.filter((j) => !j.running && j.users.size)
.sort((a, b) => Number(b.explicit) - Number(a.explicit));
for (const job of queue) {
if (this.active >= 4) break;
if ((this.origins.get(job.origin) ?? 0) >= 2) continue;
clearTimeout(job.queueTimer);
job.running = true;
this.active++;
this.origins.set(
job.origin,
(this.origins.get(job.origin) ?? 0) + 1
);
void this.run(job);
}
}
private async run(job: Job) {
const startedAt = Date.now();
const initial = this.snapshots().get(job.key);
const deadlineAt =
job.deadlineAt ?? startedAt + (job.explicit ? 15000 : 5000);
let snapshot: SourceHealthSnapshot;
try {
const result =
deadlineAt <= startedAt
? sourceHealthUnknown('timeout')
: await this.checkWithinDeadline(job, deadlineAt);
snapshot = { ...result, checkedAt: Date.now() };
} catch (error) {
snapshot = { ...sourceHealthError(error), checkedAt: Date.now() };
if (snapshot.reason === 'cancelled' && Date.now() >= deadlineAt)
snapshot.reason = 'timeout';
}
if (job.users.size && [...this.identities.values()].includes(job.key)) {
const previous = this.snapshots().get(job.key);
// An account dialog may have published newer evidence while this check ran.
if (!previous || previous === initial) {
this.publish(job.key, snapshot);
} else {
// Return the same newer evidence that the indicator displays.
snapshot = previous;
}
}
if (this.jobs.get(job.key) === job) this.jobs.delete(job.key);
this.active--;
this.origins.set(job.origin, (this.origins.get(job.origin) ?? 1) - 1);
job.resolve(snapshot);
this.drain();
}
private async checkWithinDeadline(
job: Job,
deadlineAt: number
): Promise<SourceHealthResult> {
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const stopped = new Promise<never>((_, reject) => {
onAbort = () => reject(new Error('cancelled'));
job.controller.signal.addEventListener('abort', onAbort, {
once: true,
});
timer = setTimeout(
() => {
// Also cancel the transport; the race bounds waits before a socket exists.
reject(new Error('deadline exceeded'));
job.controller.abort();
void window.electron.cancelSourceProbe(job.requestId);
},
Math.max(0, deadlineAt - Date.now())
);
});
try {
return await Promise.race([
this.probes.check(
job.playlist,
{ requestId: job.requestId, deadlineAt },
job.controller.signal
),
stopped,
]);
} finally {
clearTimeout(timer);
if (onAbort)
job.controller.signal.removeEventListener('abort', onAbort);
}
}
private publish(key: string, result: SourceHealthSnapshot) {
const previous = this.snapshots().get(key);
const snapshot = {
...result,
lastSuccessAt:
result.state === 'active'
? result.checkedAt
: previous?.lastSuccessAt,
};
this.snapshots.update((map) => new Map(map).set(key, snapshot));
}
}
@@ -15,3 +15,5 @@ export * from './lib/stalker-series.adapters';
export * from './lib/stalker-session.service';
export * from './lib/stalker-vod.utils';
export * from './lib/stalker.store';
export * from './lib/stalker-source-health.service';
@@ -1,3 +1,5 @@
import { SourceHealthEvidenceService } from '@iptvnator/services';
import { accountHealth } from '@iptvnator/shared/interfaces';
import { inject, Injectable } from '@angular/core';
import { DataService } from '@iptvnator/services';
import {
@@ -69,8 +71,32 @@ export class StalkerAccountInfoService {
private readonly stalkerSession = inject(StalkerSessionService);
private readonly portalRepair = inject(StalkerPortalRepairService);
private readonly healthEvidence = inject(SourceHealthEvidenceService, {
optional: true,
});
async fetchAccountInfo(
playlist: PlaylistMeta
): Promise<StalkerAccountSnapshot | null> {
const snapshot = await this.loadAccountInfo(playlist);
if (snapshot)
this.healthEvidence?.results.next({
playlist,
result: accountHealth(
snapshot.status === 0
? 'disabled'
: snapshot.status === 1
? 'active'
: undefined,
snapshot.expireDate,
true
),
});
return snapshot;
}
private async loadAccountInfo(
playlist: PlaylistMeta
): Promise<StalkerAccountSnapshot | null> {
if (!playlist.portalUrl || !playlist.macAddress) {
return null;
@@ -0,0 +1,84 @@
import { TestBed } from '@angular/core/testing';
import { DataService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { StalkerSourceHealthService } from './stalker-source-health.service';
import { StalkerSessionService } from './stalker-session.service';
describe('Stalker source health', () => {
let request: jest.Mock;
let ensureToken: jest.Mock;
let service: StalkerSourceHealthService;
const simple = {
_id: 'a',
portalUrl: 'https://portal.test/portal.php',
macAddress: '00:1A:79:00:00:01',
} as PlaylistMeta;
const context = () => ({
requestId: 'health',
deadlineAt: Date.now() + 5000,
});
beforeEach(() => {
request = jest.fn();
ensureToken = jest.fn().mockResolvedValue({ token: 'existing' });
TestBed.configureTestingModule({
providers: [
StalkerSourceHealthService,
{ provide: DataService, useValue: { sendIpcEvent: request } },
{ provide: StalkerSessionService, useValue: { ensureToken } },
],
});
service = TestBed.inject(StalkerSourceHealthService);
});
it('checks a simple portal without repair or catalog requests', async () => {
request.mockResolvedValue({ js: { account_info: { status: 1 } } });
expect((await service.check(simple, context())).state).toBe('active');
expect(request).toHaveBeenCalledWith(
'STALKER_REQUEST',
expect.objectContaining({
params: expect.objectContaining({ action: 'get_main_info' }),
probe: expect.any(Object),
silent: true,
})
);
});
it('only confirms explicit account status or expiry', async () => {
request.mockResolvedValueOnce({ js: { account_info: { status: 0 } } });
expect((await service.check(simple, context())).confirmedInactive).toBe(
true
);
request.mockResolvedValueOnce({ js: { login: 'user' } });
expect((await service.check(simple, context())).confirmedInactive).toBe(
false
);
});
it('times out only the health consumer while shared cold authentication continues', async () => {
jest.useFakeTimers();
try {
let resolve!: (value: { token: string }) => void;
const shared = new Promise<{ token: string }>((r) => {
resolve = r;
});
ensureToken.mockReturnValue(shared);
const full = { ...simple, isFullStalkerPortal: true };
const check = service.check(full, context());
jest.advanceTimersByTime(5000);
await expect(check).rejects.toThrow('timeout');
expect(ensureToken.mock.calls[0]).toHaveLength(1);
resolve({ token: 'foreground-token' });
await expect(shared).resolves.toEqual({
token: 'foreground-token',
});
expect(request).not.toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
it('does not send a new request after cancellation', async () => {
const controller = new AbortController();
controller.abort();
await expect(
service.check(simple, context(), controller.signal)
).rejects.toThrow('cancelled');
expect(request).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,116 @@
import { Injectable, inject } from '@angular/core';
import { DataService, withSourceProbe } from '@iptvnator/services';
import {
accountHealth,
isFullStalkerPortalPlaylist,
PlaylistMeta,
SourceHealthResult,
SourceProbeContext,
sourceHealthUnknown,
isStalkerAuthFailureResponse,
} from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
StalkerSessionService,
getStalkerPortalIdentityFromPlaylist,
} from './stalker-session.service';
import { StalkerAuthApi } from './stalker-auth.api';
import { parseStalkerDate } from './stalker-account-info.service';
import {
executeStalkerRequest,
toStalkerSessionPlaylist,
} from './stores/utils/stalker-request.utils';
@Injectable({ providedIn: 'root' })
export class StalkerSourceHealthService {
private readonly data = inject(DataService);
private readonly session = inject(StalkerSessionService);
async check(
playlist: PlaylistMeta,
probe: SourceProbeContext,
signal?: AbortSignal
): Promise<SourceHealthResult> {
const dataService = withSourceProbe(this.data, probe, signal);
let response: { js?: Record<string, unknown> };
if (isFullStalkerPortalPlaylist(playlist)) {
// Authentication is shared with playback. UI cancellation must not
// abort or shorten its slot. Only this health consumer stops waiting.
const session = await waitForSession(
this.session.ensureToken(toStalkerSessionPlaylist(playlist)),
probe.deadlineAt
);
if (Date.now() >= probe.deadlineAt)
return sourceHealthUnknown('timeout');
response = await new StalkerAuthApi(
dataService,
createLogger('SourceHealth')
).getProfile(
playlist.portalUrl!,
playlist.macAddress!,
session.token!,
getStalkerPortalIdentityFromPlaylist(playlist),
''
);
// Bare profile status is a protocol challenge, not account status.
if (!response.js?.['id'] && !response.js?.['account_info'])
return sourceHealthUnknown('auth');
} else {
response = await executeStalkerRequest(
{ dataService, stalkerSession: this.session },
playlist,
{
type: 'account_info',
action: 'get_main_info',
JsHttpRequest: '1-xml',
}
);
}
if (isStalkerAuthFailureResponse(response))
return { ...sourceHealthUnknown('auth'), state: 'inactive' };
if (!response?.js || typeof response.js !== 'object')
return sourceHealthUnknown('invalid');
const nested = response.js['account_info'];
const info =
nested && typeof nested === 'object'
? (nested as Record<string, unknown>)
: isFullStalkerPortalPlaylist(playlist)
? {}
: response.js;
const expiry = parseStalkerDate(
(info['expire_date'] ??
info['end_date'] ??
info['expire_billing_date']) as string | number | undefined
);
const status = info['status'];
return accountHealth(
status === 0 || status === '0'
? 'disabled'
: status === 1 || status === '1'
? 'active'
: status,
expiry,
Object.keys(info).length > 0 || Boolean(response.js['id'])
);
}
}
async function waitForSession<T>(
request: Promise<T>,
deadlineAt: number
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
request,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error('Source probe timeout')),
Math.max(0, deadlineAt - Date.now())
);
}),
]);
} finally {
clearTimeout(timer);
}
}
+2
View File
@@ -26,3 +26,5 @@ export * from './lib/epg-source-settings.service';
export * from './lib/xtream-connection-test.service';
export * from './lib/xtream-connection-test-state';
export * from './lib/source-health-evidence.service';
@@ -1,3 +1,4 @@
import { SourceHealthEvidenceService } from './source-health-evidence.service';
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -19,6 +20,9 @@ export interface PlaylistDeleteActionOptions {
@Injectable({ providedIn: 'root' })
export class PlaylistDeleteActionService {
private readonly healthEvidence = inject(SourceHealthEvidenceService, {
optional: true,
});
private readonly databaseService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
@@ -32,7 +36,13 @@ export class PlaylistDeleteActionService {
: this.runtime.supportsSqlite;
if (supportsElectronDelete) {
return this.deletePlaylistInElectron(playlist, options);
const deleted = await this.deletePlaylistInElectron(
playlist,
options
);
if (deleted)
this.healthEvidence?.connections.next({ id: playlist._id });
return deleted;
}
const result = await firstValueFrom(
@@ -1,3 +1,9 @@
import {
createEnvironmentInjector,
EnvironmentInjector,
Injector,
runInInjectionContext,
} from '@angular/core';
import type {
ElectronBridgeApi,
PlaylistRefreshPayload,
@@ -38,7 +44,13 @@ describe('PlaylistRefreshService', () => {
});
await expect(
new PlaylistRefreshService().refreshPlaylist(payload)
runInInjectionContext(
createEnvironmentInjector(
[],
Injector.NULL as unknown as EnvironmentInjector
),
() => new PlaylistRefreshService()
).refreshPlaylist(payload)
).rejects.toMatchObject({
message:
'Playlist refresh "playlist-refresh-cancelled" was cancelled',
@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { SourceHealthEvidenceService } from './source-health-evidence.service';
import { Injectable, inject } from '@angular/core';
import {
isPlaylistRefreshCancelledResult,
Playlist,
@@ -14,6 +15,9 @@ export interface PlaylistRefreshOptions {
providedIn: 'root',
})
export class PlaylistRefreshService {
private readonly healthEvidence = inject(SourceHealthEvidenceService, {
optional: true,
});
async refreshPlaylist(
payload: PlaylistRefreshPayload,
options?: PlaylistRefreshOptions
@@ -22,13 +26,15 @@ export class PlaylistRefreshService {
throw new Error('Playlist refresh is only available in Electron');
}
const unsubscribe = window.electron.onPlaylistRefreshEvent?.((event) => {
if (event.operationId !== payload.operationId) {
return;
}
const unsubscribe = window.electron.onPlaylistRefreshEvent?.(
(event) => {
if (event.operationId !== payload.operationId) {
return;
}
options?.onEvent?.(event);
});
options?.onEvent?.(event);
}
);
try {
const result = await window.electron.refreshPlaylist(payload);
@@ -39,6 +45,14 @@ export class PlaylistRefreshService {
error.name = 'AbortError';
throw error;
}
this.healthEvidence?.results.next({
playlist: result,
result: {
state: 'active',
reason: 'available',
confirmedInactive: false,
},
});
return result;
} finally {
unsubscribe?.();
@@ -51,7 +65,8 @@ export class PlaylistRefreshService {
}
try {
const result = await window.electron.cancelPlaylistRefresh(operationId);
const result =
await window.electron.cancelPlaylistRefresh(operationId);
return result.success;
} catch (error) {
console.error('Failed to cancel playlist refresh:', error);
@@ -67,6 +67,35 @@ describe('PlaylistsService', () => {
return service;
}
it('announces a committed SQLite connection edit', async () => {
const playlist = { _id: 'source', url: 'https://old.test/list' } as Playlist;
testWindow.electron = {
dbGetAppState: jest.fn().mockResolvedValue('1'),
dbSetAppState: jest.fn(),
dbGetAppPlaylists: jest.fn().mockResolvedValue([]),
dbGetAppPlaylist: jest.fn().mockResolvedValue(playlist),
dbUpsertAppPlaylist: jest.fn().mockResolvedValue(undefined),
};
const service = createService();
const next = jest.fn();
Object.assign(service, { healthEvidence: { connections: { next } } });
await firstValueFrom(service.updatePlaylistMeta({ ...playlist, title: 'Source', count: 0, importDate: '', autoRefresh: false, serverUrl: 'https://new.test' }));
expect(next).toHaveBeenCalledWith({ id: 'source', playlist: expect.objectContaining({ serverUrl: 'https://new.test' }) });
});
it('announces committed deletion but does not retire health after a failed write', async () => {
const service = createService();
const next = jest.fn();
Object.assign(service, { healthEvidence: { connections: { next } } });
await firstValueFrom(service.deletePlaylist('retired'));
expect(next).toHaveBeenCalledWith({ id: 'retired' });
next.mockClear();
const failed = createService({ delete: jest.fn(() => { throw new Error('write failed'); }) });
Object.assign(failed, { healthEvidence: { connections: { next } } });
await expect(firstValueFrom(failed.deletePlaylist('kept'))).rejects.toThrow();
expect(next).not.toHaveBeenCalled();
});
it('resolves the parser from a CommonJS default dynamic import shape', () => {
const parse = jest.fn();
@@ -1,3 +1,4 @@
import { SourceHealthEvidenceService } from './source-health-evidence.service';
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
@@ -17,6 +18,7 @@ import {
Observable,
of,
switchMap,
tap,
} from 'rxjs';
import {
Channel,
@@ -101,6 +103,7 @@ export function resolvePlaylistParser(parserModule: PlaylistParserModule) {
providedIn: 'root',
})
export class PlaylistsService {
private readonly healthEvidence = inject(SourceHealthEvidenceService, { optional: true });
private readonly dbService = inject(NgxIndexedDBService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
@@ -394,6 +397,7 @@ export class PlaylistsService {
} else {
await electron.dbUpsertAppPlaylist(playlist, operationId);
}
this.healthEvidence?.connections.next({ id: playlist._id, playlist });
return playlist;
});
}
@@ -406,6 +410,7 @@ export class PlaylistsService {
}
await electron.dbUpsertAppPlaylists(playlists);
playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist }));
return playlists;
});
}
@@ -554,6 +559,7 @@ export class PlaylistsService {
);
return delete$.pipe(
tap(() => this.healthEvidence?.connections.next({ id: playlistId })),
switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))),
map(() => ({ success: true }))
);
@@ -1294,6 +1300,7 @@ export class PlaylistsService {
const electron = this.electronApi;
if (electron) {
await electron.dbDeleteAllPlaylists();
this.healthEvidence?.connections.next({});
}
return undefined;
}
@@ -1,3 +1,8 @@
import { SourceHealthEvidenceService } from './source-health-evidence.service';
import {
accountHealth,
sourceHealthUnknown,
} from '@iptvnator/shared/interfaces';
import { Injectable, inject } from '@angular/core';
import {
normalizeXtreamServerUrl,
@@ -53,6 +58,9 @@ const XTREAM_STATUS_ACTIONS = [
})
export class PortalStatusService {
private readonly dataService = inject(DataService);
private readonly healthEvidence = inject(SourceHealthEvidenceService, {
optional: true,
});
/**
* Process-lifetime cache shared across all consumers (playlist switcher,
@@ -228,6 +236,24 @@ export class PortalStatusService {
connection.username,
connection.password
);
const info = response?.user_info;
if (info) {
const result = accountHealth(
info.status,
resolveXtreamPortalExpiration(response) ?? undefined,
info.auth === true || info.auth === 1 || info.auth === '1'
);
this.healthEvidence?.results.next({
playlist: { _id: '', ...connection },
result:
(info.auth === false ||
info.auth === 0 ||
info.auth === '0') &&
!result.confirmedInactive
? { ...sourceHealthUnknown('auth'), state: 'inactive' }
: result,
});
}
// A passive check started before this evidence cannot overwrite it.
this.inFlight.delete(key);
this.cache.set(key, {
@@ -91,6 +91,13 @@ export class RuntimeCapabilitiesService {
);
}
get supportsSourceHealth(): boolean {
return (
this.hasElectronMethod('probeM3uSource') &&
this.hasElectronMethod('cancelSourceProbe')
);
}
get supportsEpg(): boolean {
return (
this.supportsEpgImport &&
@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
import {
PlaylistMeta,
SourceHealthResult,
SourceProbeContext,
} from '@iptvnator/shared/interfaces';
import type { DataService } from './data.service';
@Injectable({ providedIn: 'root' })
export class SourceHealthEvidenceService {
/** Committed desktop inventory changes; an absent ID resets the inventory. */
readonly connections = new Subject<{
id?: string;
playlist?: Partial<PlaylistMeta>;
}>();
readonly results = new Subject<{
playlist: Partial<PlaylistMeta>;
result: SourceHealthResult;
}>();
}
/** A request-local facade: never mutates the shared transport or session. */
export function withSourceProbe(
data: DataService,
probe?: SourceProbeContext,
signal?: AbortSignal
): DataService {
if (!probe) return data;
const facade = Object.create(data) as DataService;
facade.sendIpcEvent = <T>(type: string, payload?: unknown) => {
if (signal?.aborted)
return Promise.reject(new Error('Source probe cancelled'));
if (Date.now() >= probe.deadlineAt)
return Promise.reject(new Error('Source probe deadline exceeded'));
return data.sendIpcEvent<T>(type, {
...(payload as object),
probe,
silent: true,
});
};
return facade;
}
+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');
}
@@ -1,3 +1,6 @@
import { Injector } from '@angular/core';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { Injectable, NgZone, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { normalizeStoredStalkerAccountInfo } from '@iptvnator/portal/stalker/data-access';
@@ -21,13 +24,15 @@ import type { SourceExpiryFacts } from './dashboard-source-expiry.util';
*/
@Injectable({ providedIn: 'root' })
export class DashboardSourceExpiryService {
private readonly injector = inject(Injector);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly ngZone = inject(NgZone);
private readonly playlistsService = inject(PlaylistsService);
private readonly portalStatusService = inject(PortalStatusService);
private readonly factsState = signal<ReadonlyMap<string, SourceExpiryFacts>>(
new Map()
);
private readonly factsState = signal<
ReadonlyMap<string, SourceExpiryFacts>
>(new Map());
/** Facts per playlist id; entries land as each source's lookup resolves. */
readonly facts = this.factsState.asReadonly();
@@ -80,6 +85,23 @@ export class DashboardSourceExpiryService {
private async loadFacts(
playlist: PlaylistMeta
): Promise<SourceExpiryFacts | null> {
if (
this.runtime.supportsSourceHealth &&
(isXtreamAccountPlaylist(playlist) ||
isStalkerAccountPlaylist(playlist))
) {
const result = await this.injector
.get(SourceHealthService)
.check(playlist);
if (result.expiresAtSeconds || result.state === 'expired')
return {
expiresAtSeconds: result.expiresAtSeconds ?? null,
reportedExpired: result.state === 'expired',
};
if (isStalkerAccountPlaylist(playlist))
return this.loadStalkerFacts(playlist);
return null;
}
if (isXtreamAccountPlaylist(playlist)) {
return this.loadXtreamFacts(playlist);
}