mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(playlist): show desktop health indicators for network sources (#1592)
This commit is contained in:
1 parent
a417826b01
commit
62655a8b5d
67 files changed
+2302
-60
No files matched your search
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "Проверяется…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+37
-25
@@ -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 {
|
||||
|
||||
+33
-8
@@ -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
|
||||
|
||||
+19
-7
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user