diff --git a/.changes/playlist-network-source-status.md b/.changes/playlist-network-source-status.md new file mode 100644 index 000000000..d5efc01db --- /dev/null +++ b/.changes/playlist-network-source-status.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index e3a5ac38a..3e4a2a87d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/CLAUDE.md b/CLAUDE.md index 686ac4611..6a58da90c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 4d3f68256..3702752cd 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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; } diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 9ff60b33b..3cb1584fd 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -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( diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 185427f30..580167b15 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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) => 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; @@ -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; diff --git a/apps/electron-backend/src/app/events/m3u-source-probe.spec.ts b/apps/electron-backend/src/app/events/m3u-source-probe.spec.ts new file mode 100644 index 000000000..0325779b2 --- /dev/null +++ b/apps/electron-backend/src/app/events/m3u-source-probe.spec.ts @@ -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('login required'); + 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); + }); +}); diff --git a/apps/electron-backend/src/app/events/m3u-source-probe.ts b/apps/electron-backend/src/app/events/m3u-source-probe.ts new file mode 100644 index 000000000..42c9ace77 --- /dev/null +++ b/apps/electron-backend/src/app/events/m3u-source-probe.ts @@ -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 { + const control = sourceProbeControl(owner, payload.probe); + let stream: Readable | undefined; + try { + for (const ranged of [true, false]) { + const response = await requestWithValidatedRedirects( + 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) + ); +} diff --git a/apps/electron-backend/src/app/events/source-probe-control.ts b/apps/electron-backend/src/app/events/source-probe-control.ts new file mode 100644 index 000000000..6bb34c362 --- /dev/null +++ b/apps/electron-backend/src/app/events/source-probe-control.ts @@ -0,0 +1,34 @@ +import { ipcMain } from 'electron'; +import { + SOURCE_HEALTH_CANCEL, + SourceProbeContext, +} from '@iptvnator/shared/interfaces'; + +const requests = new Map(); +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(); + }); +} diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 6e7528e78..aec0f7450 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -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 | 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); } } diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index 78577264d..0e490cee3 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -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, diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 615d64fe5..421f355ed 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -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; 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); diff --git a/apps/electron-backend/src/app/util/validated-axios.spec.ts b/apps/electron-backend/src/app/util/validated-axios.spec.ts index 5e6e0a674..96332d254 100644 --- a/apps/electron-backend/src/app/util/validated-axios.spec.ts +++ b/apps/electron-backend/src/app/util/validated-axios.spec.ts @@ -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, diff --git a/apps/electron-backend/src/app/util/validated-axios.ts b/apps/electron-backend/src/app/util/validated-axios.ts index 619cae0d9..8fca12e67 100644 --- a/apps/electron-backend/src/app/util/validated-axios.ts +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -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( 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( diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 0b50087df..69827f094 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -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(); diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index 1e869d337..804a4994e 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -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 }, diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 6d5c25929..2594ccfe8 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -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; @@ -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 || diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 5d17950b3..4c154c958 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 0e5d254b5..ce080b87e 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 52a797aa8..d34d6f1d4 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 0761eb595..b73b1961c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 706dbe232..84569f38c 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index f74f6547d..5dc5045b3 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index b707035e4..6ad2e3f61 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 4de8cc01f..7eda5a55a 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 71ba7a563..a7ec42d07 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 3b2035361..9cbe42c85 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 7d3946c53..26a435a12 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index c4c6bd2d9..7660d848b 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index bc4a3e494..b78dc22a9 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 3a1df2f80..389321381 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 35535b9f9..e573e468c 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index cc8ce18b9..04b7c078e 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Проверяется…" + } } } diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 3ab6243bd..584c17e7a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 7dc4fb7cc..89984fbfb 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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…" + } } } diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 2e550f5c7..c99abfdca 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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…" + } } } diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 5cea2d8d3..39cfd5346 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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. diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index cffee9bdc..68708b839 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 045caf399..2e7d8726e 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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. diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html index 102890e08..737cbaa19 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html @@ -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" > @if (showSearchField()) { -
- search - - @if (searchQuery()) { - - } -
+
+ search + + @if (searchQuery()) { + + } +
} @if (showTypeFilters()) { @@ -146,7 +144,12 @@ {{ getPlaylistIcon(playlist) }} - @if (playlist.serverUrl) { + @if (runtime.supportsSourceHealth) { + + } @else if (playlist.serverUrl) { {{ 'WORKSPACE.SHELL.ACCOUNT_INFO' | translate }} } + @if (runtime.supportsSourceHealth && sourceHealthType(playlist)) { + + } @if (canRefreshPlaylist(playlist)) { - + } @if (canCancelBusyAction()) {