diff --git a/apps/electron-backend/src/app/events/url-safety.ts b/apps/electron-backend/src/app/events/url-safety.ts index 501f627fc..bfecb3402 100644 --- a/apps/electron-backend/src/app/events/url-safety.ts +++ b/apps/electron-backend/src/app/events/url-safety.ts @@ -19,6 +19,16 @@ export class UnsafeUrlError extends Error { export interface RemoteUrlPolicy { /** When true, private/reserved targets are permitted (default false). */ allowPrivateNetworks?: boolean; + /** + * When false, allowPrivateNetworks only applies to the initially validated + * origin; cross-origin redirects must still resolve to public addresses. + */ + allowPrivateNetworkRedirects?: boolean; + /** + * When true together with allowPrivateNetworks, hostnames are still resolved + * for socket pinning. Private/reserved resolved addresses are accepted. + */ + pinAllowedPrivateNetworkHosts?: boolean; /** Injectable DNS resolver. Defaults to `dns.lookup`; overridable in tests. */ resolveHostname?: (hostname: string) => Promise; } @@ -214,11 +224,38 @@ export async function validateRemoteUrl( throw new UnsafeUrlError('URL credentials are not supported'); } + const hostname = normalizeHostname(url.hostname); if (policy.allowPrivateNetworks) { - return { url }; + if (!policy.pinAllowedPrivateNetworkHosts) { + return { url }; + } + + if (isIP(hostname) !== 0) { + return { url, addresses: [hostname] }; + } + + const resolveHostname = + policy.resolveHostname ?? defaultResolveHostname; + let addresses: readonly string[]; + try { + addresses = await resolveHostname(hostname); + } catch { + throw new UnsafeUrlError('URL host could not be resolved'); + } + + if ( + addresses.length === 0 || + addresses.some((address) => isIP(normalizeHostname(address)) === 0) + ) { + throw new UnsafeUrlError('URL host could not be resolved'); + } + + return { + url, + addresses: addresses.map((address) => normalizeHostname(address)), + }; } - const hostname = normalizeHostname(url.hostname); if (isLocalHostname(hostname) || isPrivateOrReservedIp(hostname)) { throw new UnsafeUrlError( 'URL points to a private or local network address' 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 875406256..837d7da2b 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -86,6 +86,82 @@ describe('XtreamEvents session cancellation', () => { expect(requestedUrl.searchParams.get('username')).toBe('user'); }); + it('follows validated redirects for range GET media probes', async () => { + const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL'); + const destroyProbeBody = jest.fn(); + expect(probeHandler).toBeDefined(); + + axiosMock + .mockImplementationOnce((config: { url?: string }) => + Promise.resolve({ + status: 302, + headers: { location: '/media.ts' }, + config, + }) + ) + .mockImplementationOnce((config: { url?: string }) => + Promise.resolve({ + status: 206, + headers: {}, + data: { destroy: destroyProbeBody }, + config, + }) + ); + + const result = (await probeHandler?.( + {}, + { + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', + method: 'GET', + } + )) as { status: number; url: string }; + + expect(result.status).toBe(206); + expect(result.url).toBe('http://localhost:3211/media.ts'); + expect(axiosMock).toHaveBeenCalledTimes(2); + const firstRequest = axiosMock.mock.calls[0][0] as { + headers?: Record; + maxRedirects?: number; + method?: string; + responseType?: string; + }; + expect(firstRequest.method).toBe('GET'); + expect(firstRequest.headers).toEqual( + expect.objectContaining({ Range: 'bytes=0-4095' }) + ); + expect(firstRequest.maxRedirects).toBe(0); + expect(firstRequest.responseType).toBe('stream'); + expect(destroyProbeBody).toHaveBeenCalledTimes(1); + }); + + it('rejects private cross-origin redirects for range GET media probes', async () => { + const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL'); + expect(probeHandler).toBeDefined(); + + axiosMock.mockResolvedValueOnce({ + status: 302, + headers: { location: 'http://127.0.0.1/admin' }, + config: { + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', + }, + }); + + const result = (await probeHandler?.( + {}, + { + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', + method: 'GET', + } + )) as { error?: string; status: number; url: string }; + + expect(result).toEqual({ + error: 'URL points to a private or local network address', + status: 0, + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', + }); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + it('aborts requests that were registered with only a session id', async () => { const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 22d58326f..c712d12f3 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -11,7 +11,7 @@ import { normalizeXtreamServerUrl, } from '@iptvnator/shared/interfaces'; import { emitPortalDebugEvent } from './portal-debug.events'; -import { assertRemoteUrlAllowed, UnsafeUrlError } from './url-safety'; +import { UnsafeUrlError } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; export default class XtreamEvents { @@ -299,45 +299,37 @@ ipcMain.handle( method?: 'GET' | 'HEAD'; } ) => { - // Probe URLs must be http(s), but may target private/LAN Xtream hosts - // for self-hosted setups. Redirects stay disabled below so a validated - // URL cannot bounce to a different private target. - try { - await assertRemoteUrlAllowed(payload.url, { - allowPrivateNetworks: true, - }); - } catch (error) { - return { - status: 0, - url: payload.url, - error: - error instanceof UnsafeUrlError - ? error.message - : 'Invalid URL', - }; - } - + const method = payload.method ?? 'HEAD'; const config: AxiosRequestConfig = { - method: payload.method ?? 'HEAD', + method, url: payload.url, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + ...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}), }, timeout: 10000, - // SSRF hardening: do NOT follow redirects. assertRemoteUrlAllowed - // validated payload.url, but a validated public host could 3xx to an - // internal address that would never be re-checked. validateStatus - // below returns the 3xx as the probe result instead of following it. - maxRedirects: 0, + responseType: method === 'GET' ? 'stream' : undefined, validateStatus: () => true, }; try { - const response = await axios(config); + const response = await requestWithValidatedRedirects( + payload.url, + config, + { + allowPrivateNetworkRedirects: false, + allowPrivateNetworks: true, + pinAllowedPrivateNetworkHosts: true, + } + ); + const responseBody = response.data as + | { destroy?: () => void } + | undefined; + responseBody?.destroy?.(); return { status: response.status, - url: payload.url, + url: response.config?.url ?? payload.url, }; } catch (error) { if (axios.isAxiosError(error) && error.response) { @@ -347,6 +339,14 @@ ipcMain.handle( }; } + if (error instanceof UnsafeUrlError) { + return { + status: 0, + url: payload.url, + error: error.message, + }; + } + return { status: 0, url: payload.url, 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 ff4146eca..28f58b7c7 100644 --- a/apps/electron-backend/src/app/util/validated-axios.spec.ts +++ b/apps/electron-backend/src/app/util/validated-axios.spec.ts @@ -155,6 +155,103 @@ describe('requestWithValidatedRedirects', () => { expect(requestConfig).not.toHaveProperty('agentFactory'); }); + it('allows same-origin private redirects when private redirect access is disabled', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: '/media.ts' }, + }) + .mockResolvedValueOnce({ + status: 206, + headers: {}, + data: 'ok', + }); + + const response = await requestWithValidatedRedirects( + 'http://192.168.1.10/start', + { method: 'GET' }, + { + allowPrivateNetworkRedirects: false, + allowPrivateNetworks: true, + } + ); + + expect(response.status).toBe(206); + expect(axiosMock).toHaveBeenCalledTimes(2); + expect(axiosMock.mock.calls[1][0].url).toBe( + 'http://192.168.1.10/media.ts' + ); + }); + + it('rejects cross-origin private redirects when private redirect access is disabled', async () => { + axiosMock.mockResolvedValueOnce({ + status: 302, + headers: { location: 'http://127.0.0.1/admin' }, + }); + + await expect( + requestWithValidatedRedirects( + 'http://192.168.1.10/start', + { method: 'GET' }, + { + allowPrivateNetworkRedirects: false, + allowPrivateNetworks: true, + } + ) + ).rejects.toMatchObject({ + message: 'URL points to a private or local network address', + }); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + + it('reuses the initially validated addresses for same-origin redirects when private redirect access is disabled', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: '/media.ts' }, + }) + .mockResolvedValueOnce({ + status: 206, + headers: {}, + data: 'ok', + }); + const { factory, lookups } = createCapturingAgentFactory(); + const resolveHostname = jest.fn(async () => ['93.184.216.34']); + + const response = await requestWithValidatedRedirects( + 'https://portal.example/start', + { agentFactory: factory, method: 'GET' }, + { + allowPrivateNetworkRedirects: false, + allowPrivateNetworks: true, + pinAllowedPrivateNetworkHosts: true, + resolveHostname, + } + ); + + const resolvePinnedAddress = async (callIndex: number) => { + return new Promise((resolve, reject) => { + lookups[callIndex]( + 'portal.example', + { all: false, family: 0 } as LookupOptions, + (error, address) => { + if (error) { + reject(error); + return; + } + resolve(address); + } + ); + }); + }; + + expect(response.status).toBe(206); + expect(resolveHostname).toHaveBeenCalledTimes(1); + expect(factory.createHttpsAgent).toHaveBeenCalledTimes(2); + await expect(resolvePinnedAddress(0)).resolves.toBe('93.184.216.34'); + await expect(resolvePinnedAddress(1)).resolves.toBe('93.184.216.34'); + }); + it('revalidates and repins every redirect hop', async () => { axiosMock .mockResolvedValueOnce({ diff --git a/apps/electron-backend/src/app/util/validated-axios.ts b/apps/electron-backend/src/app/util/validated-axios.ts index ab554bd8a..6b5e7fa4c 100644 --- a/apps/electron-backend/src/app/util/validated-axios.ts +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -132,6 +132,33 @@ function pinRequestToValidatedAddresses( }; } +function getRedirectValidationPolicy( + currentUrl: string, + initialOrigin: string | undefined, + policy: RemoteUrlPolicy +): RemoteUrlPolicy { + if ( + !initialOrigin || + !policy.allowPrivateNetworks || + policy.allowPrivateNetworkRedirects !== false + ) { + return policy; + } + + const parsedUrl = new URL(currentUrl); + if (parsedUrl.origin === initialOrigin) { + return { + ...policy, + pinAllowedPrivateNetworkHosts: false, + }; + } + + return { + ...policy, + allowPrivateNetworks: false, + }; +} + /** * Runs an Axios request while validating the initial URL and every redirect. * Redirects are followed manually so each target passes through the same @@ -148,14 +175,31 @@ export async function requestWithValidatedRedirects( ((status: number) => status >= 200 && status < 300); let currentUrl = rawUrl; let requestConfig = { ...config }; + let initialOrigin: string | undefined; + let initialAddresses: readonly string[] | undefined; for (let redirectCount = 0; ; redirectCount += 1) { - const validatedTarget = await validateRemoteUrl(currentUrl, policy); + const validatedTarget = await validateRemoteUrl( + currentUrl, + getRedirectValidationPolicy(currentUrl, initialOrigin, policy) + ); const validatedUrl = validatedTarget.url; + const isInitialRequest = !initialOrigin; + if (isInitialRequest) { + initialOrigin = validatedUrl.origin; + initialAddresses = validatedTarget.addresses; + } + const addresses = + !isInitialRequest && + policy.allowPrivateNetworks && + policy.allowPrivateNetworkRedirects === false && + validatedUrl.origin === initialOrigin + ? initialAddresses + : validatedTarget.addresses; const pinnedConfig = pinRequestToValidatedAddresses( requestConfig, validatedUrl, - validatedTarget.addresses + addresses ); const response = await axios({ ...pinnedConfig, diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index d8c6dd97f..965e90dda 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -4,7 +4,10 @@ import { Router } from '@angular/router'; import { Actions } from '@ngrx/effects'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { TranslateService } from '@ngx-translate/core'; -import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; import { MockProvider } from 'ng-mocks'; import { EMPTY, of } from 'rxjs'; @@ -35,7 +38,7 @@ class MockSettingsService { const DEFAULT_SETTINGS: Settings = { player: VideoPlayer.VideoJs, epgUrl: [], - streamFormat: StreamFormat.M3u8StreamFormat, + streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, showCaptions: false, diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 8bbabcd1e..cbdaf7674 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -32,7 +32,7 @@ export function createSettingsForm( ...(supportsEpg ? { epgUrl: new FormArray>([]) } : {}), - streamFormat: StreamFormat.M3u8StreamFormat, + streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, showCaptions: false, @@ -101,7 +101,7 @@ export function createSettingsFromFormValue( return { player: value.player ?? VideoPlayer.VideoJs, - streamFormat: value.streamFormat ?? StreamFormat.M3u8StreamFormat, + streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false, language: value.language ?? Language.ENGLISH, showCaptions: value.showCaptions ?? false, diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts index 822a7280c..c5401509c 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -91,7 +91,10 @@ describe('SettingsPlaybackSectionComponent', () => { (player) => { fixture.componentRef.setInput('form', createForm(player)); fixture.componentRef.setInput('isDesktop', true); - fixture.componentRef.setInput('supportsManagedExternalPlayers', true); + fixture.componentRef.setInput( + 'supportsManagedExternalPlayers', + true + ); fixture.detectChanges(); expect( @@ -128,11 +131,29 @@ describe('SettingsPlaybackSectionComponent', () => { ).not.toBeNull(); }); + it('offers automatic Xtream stream format selection', async () => { + fixture.detectChanges(); + + fixture.nativeElement + .querySelector('[data-test-id="select-stream-format"]') + .click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const option = document.body.querySelector('[data-test-id="auto"]'); + + expect(option).not.toBeNull(); + expect(option?.textContent).toContain('auto'); + }); + it('keeps the double-click option visible when path settings are unavailable', () => { fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV)); fixture.componentRef.setInput('isDesktop', true); fixture.componentRef.setInput('supportsManagedExternalPlayers', true); - fixture.componentRef.setInput('supportsExternalPlayerPathSettings', false); + fixture.componentRef.setInput( + 'supportsExternalPlayerPathSettings', + false + ); fixture.detectChanges(); expect( @@ -173,7 +194,10 @@ describe('SettingsPlaybackSectionComponent', () => { it('shows MPV bundle guidance and the IINA executable tip for desktop MPV playback', () => { fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV)); fixture.componentRef.setInput('isDesktop', true); - fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true); + fixture.componentRef.setInput( + 'supportsExternalPlayerPathSettings', + true + ); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain( @@ -193,7 +217,10 @@ describe('SettingsPlaybackSectionComponent', () => { fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV)); fixture.componentRef.setInput('isDesktop', true); fixture.componentRef.setInput('supportsManagedExternalPlayers', true); - fixture.componentRef.setInput('supportsExternalPlayerPathSettings', false); + fixture.componentRef.setInput( + 'supportsExternalPlayerPathSettings', + false + ); fixture.detectChanges(); expect(fixture.nativeElement.textContent).not.toContain( @@ -212,7 +239,10 @@ describe('SettingsPlaybackSectionComponent', () => { it('shows VLC bundle guidance without the IINA tip for desktop VLC playback', () => { fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC)); fixture.componentRef.setInput('isDesktop', true); - fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true); + fixture.componentRef.setInput( + 'supportsExternalPlayerPathSettings', + true + ); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain( @@ -232,7 +262,10 @@ describe('SettingsPlaybackSectionComponent', () => { fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC)); fixture.componentRef.setInput('isDesktop', true); fixture.componentRef.setInput('supportsManagedExternalPlayers', true); - fixture.componentRef.setInput('supportsExternalPlayerPathSettings', false); + fixture.componentRef.setInput( + 'supportsExternalPlayerPathSettings', + false + ); fixture.detectChanges(); expect(fixture.nativeElement.textContent).not.toContain( @@ -247,7 +280,10 @@ describe('SettingsPlaybackSectionComponent', () => { it('does not show external-player path guidance for embedded players', () => { fixture.componentRef.setInput('isDesktop', true); - fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true); + fixture.componentRef.setInput( + 'supportsExternalPlayerPathSettings', + true + ); fixture.detectChanges(); expect(fixture.nativeElement.textContent).not.toContain( @@ -266,7 +302,10 @@ describe('SettingsPlaybackSectionComponent', () => { it('shows MPV command-line arguments only when MPV is selected', () => { fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV)); fixture.componentRef.setInput('isDesktop', true); - fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true); + fixture.componentRef.setInput( + 'supportsExternalPlayerPathSettings', + true + ); fixture.detectChanges(); expect( @@ -292,7 +331,10 @@ describe('SettingsPlaybackSectionComponent', () => { it('shows VLC command-line arguments only when VLC is selected', () => { fixture.componentRef.setInput('form', createForm(VideoPlayer.VLC)); fixture.componentRef.setInput('isDesktop', true); - fixture.componentRef.setInput('supportsExternalPlayerPathSettings', true); + fixture.componentRef.setInput( + 'supportsExternalPlayerPathSettings', + true + ); fixture.detectChanges(); expect( @@ -319,7 +361,7 @@ describe('SettingsPlaybackSectionComponent', () => { function createForm(player = VideoPlayer.VideoJs): FormGroup { return new FormGroup({ player: new FormControl(player), - streamFormat: new FormControl(StreamFormat.M3u8StreamFormat), + streamFormat: new FormControl(StreamFormat.AutoStreamFormat), openStreamOnDoubleClick: new FormControl(false), showExternalPlaybackBar: new FormControl(true), mpvPlayerPath: new FormControl(''), diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 5da488c9c..75e9cc7b4 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -80,7 +80,7 @@ export class MockRouter { const DEFAULT_SETTINGS = { player: VideoPlayer.VideoJs, - streamFormat: StreamFormat.M3u8StreamFormat, + streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, showCaptions: false, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 5a000420d..71fb991c6 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "تشغيل الأرشيف", + "RETURN_TO_LIVE": "العودة إلى البث المباشر", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 4f9ba1acc..b371d0f4d 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "تشغيل الأرشيف", + "RETURN_TO_LIVE": "رجع للبث المباشر", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index cc570b499..df7327856 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Прайграванне архіва", + "RETURN_TO_LIVE": "Вярнуцца да эфіру", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 101a56113..117b00e85 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Archivwiedergabe", + "RETURN_TO_LIVE": "Zurück zum Live-TV", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index ad7c709e3..cdb79fd75 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Αναπαραγωγή αρχείου", + "RETURN_TO_LIVE": "Επιστροφή στη ζωντανή ροή", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 505e9b732..ab750db4d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Archive playback", + "RETURN_TO_LIVE": "Return to live", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 399cc65b8..c5e6613f8 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Reproducción de archivo", + "RETURN_TO_LIVE": "Volver al directo", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index b8442a44f..f3b44c878 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Lecture archive", + "RETURN_TO_LIVE": "Retour au direct", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index d52e18696..c430ff912 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Riproduzione archivio", + "RETURN_TO_LIVE": "Torna alla diretta", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index f6a216345..38fb527a8 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "アーカイブ再生", + "RETURN_TO_LIVE": "ライブに戻る", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index b06585608..861dd3073 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "아카이브 재생", + "RETURN_TO_LIVE": "라이브로 돌아가기", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index b4ad2049b..f1c8b2ec3 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Archief afspelen", + "RETURN_TO_LIVE": "Terug naar live", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ee6f76613..df24c4bac 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Odtwarzanie archiwum", + "RETURN_TO_LIVE": "Wróć do transmisji na żywo", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e617dde18..bcb91447a 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Reprodução do arquivo", + "RETURN_TO_LIVE": "Voltar ao vivo", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 35f25f1ea..2f18227eb 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Архивная запись", + "RETURN_TO_LIVE": "Вернуться к эфиру", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 21040126d..952073f9d 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "Arşiv oynatma", + "RETURN_TO_LIVE": "Canlı yayına dön", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 0b8f7606e..70f0d7e64 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "回看播放", + "RETURN_TO_LIVE": "返回直播", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ed249feea..e3d4e7db2 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -599,6 +599,8 @@ "TRUST_TLS_HOST_TITLE": "Trust invalid certificate?", "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", + "ARCHIVE_PLAYBACK": "回看播放", + "RETURN_TO_LIVE": "返回直播", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel" }, diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 6f4d581fe..34b5de2c6 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -133,6 +133,15 @@ configured Xtream, Stalker, and playlist providers retain private-network support, but still require HTTP(S), reject embedded credentials, and validate redirects. +Callers that allow private-network provider URLs but only need redirects within +the same provider origin should pass both `allowPrivateNetworkRedirects: false` +and `pinAllowedPrivateNetworkHosts: true` to the validated Axios helper. This +keeps same-origin LAN redirects working, reuses the initially resolved addresses +for same-origin redirect hops, and requires cross-origin redirects to resolve to +public addresses. A provider-controlled URL cannot bounce the Electron main +process to another private or loopback host or rebind the same hostname between +redirect hops. + Remote playlist TLS certificates are validated by default. The renderer can persist a host-scoped invalid-certificate trust decision for a playlist or EPG source host. The `IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 39605087b..ec1f7b5e8 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -65,10 +65,36 @@ and private-network checks. ## Playback URL Formats When account info includes `user_info.allowed_output_formats`, the current -Xtream playlist keeps those formats for the active session. Live stream URL -construction falls back to the first provider-allowed format when the selected -application format is not allowed by the portal. +Xtream playlist keeps those formats for the active session. The default +application format is `auto`: live stream URL construction chooses `m3u8` when +the provider allows HLS, falls back to `ts` when MPEG-TS is the only known +standard format, and otherwise uses the first provider-advertised format. If +the provider does not advertise output formats, `auto` falls back to `m3u8`. +Manual `ts` and `m3u8` settings remain supported; when a manual setting is not +allowed by the portal, URL construction falls back to the first +provider-allowed format. If stored Xtream playback credentials contain an invalid server URL or blank username/password, stream URL construction returns an empty URL instead of throwing during playback. + +## Catch-Up Playback URLs + +Xtream-compatible portals differ on archive playback URL shape. IPTVnator +supports these catch-up variants: + +1. REST-style `/timeshift/{username}/{password}/{duration}/{start}/{streamId}.ts` + and `.m3u8`. +2. Legacy `/streaming/timeshift.php?username=...&password=...&stream=...&start=...&duration=...` + with optional `extension=ts` or `extension=m3u8`. + +Electron probes concrete catch-up variants before caching a playlist-level +choice. The cache key includes the playlist id and the normalized +`allowed_output_formats` advertised by the provider, so a catch-up variant +detected before account capabilities are known cannot force stale MPEG-TS URLs +after the portal later reports HLS-only playback. The probe uses a short range +`GET`, follows only validated redirects, and accepts only `200` or `206` as +playable. MPEG-TS is preferred before HLS when the provider allows it because +some portals return a valid HLS manifest while the first media segment fails in +Chromium/video.js. PWA fallback keeps the REST MPEG-TS URL when no Electron +probe API is available. diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index 92db66b06..c1f837f94 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -1,7 +1,8 @@ -import { signal } from '@angular/core'; +import { signal, WritableSignal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { DatabaseService, SettingsStore } from '@iptvnator/services'; import { + StreamFormat, XtreamSerieEpisode, XtreamVodDetails, } from '@iptvnator/shared/interfaces'; @@ -14,6 +15,7 @@ describe('XtreamUrlService', () => { getAppState: jest.Mock, [string]>; setAppState: jest.Mock, [string, string]>; }; + let streamFormat: WritableSignal; const credentials: XtreamCredentials = { serverUrl: 'http://demo.example', @@ -23,6 +25,7 @@ describe('XtreamUrlService', () => { const originalElectron = window.electron; beforeEach(() => { + streamFormat = signal(StreamFormat.TsStreamFormat); databaseService = { getAppState: jest.fn().mockResolvedValue(null), setAppState: jest.fn().mockResolvedValue(undefined), @@ -35,7 +38,7 @@ describe('XtreamUrlService', () => { { provide: SettingsStore, useValue: { - streamFormat: signal('ts'), + streamFormat, }, }, ], @@ -73,6 +76,50 @@ describe('XtreamUrlService', () => { expect(url).toBe('http://demo.example/live/demo/secret/101.m3u8'); }); + it('auto-selects HLS for live streams when the provider allows it', () => { + streamFormat.set(StreamFormat.AutoStreamFormat); + + const url = service.constructLiveUrl( + { + ...credentials, + allowedOutputFormats: ['ts', 'm3u8'], + }, + 101 + ); + + expect(url).toBe('http://demo.example/live/demo/secret/101.m3u8'); + }); + + it('auto-selects MPEG-TS for live streams when it is the only provider format', () => { + streamFormat.set(StreamFormat.AutoStreamFormat); + + const url = service.constructLiveUrl( + { + ...credentials, + allowedOutputFormats: ['ts'], + }, + 101 + ); + + expect(url).toBe('http://demo.example/live/demo/secret/101.ts'); + }); + + it('uses HLS as the auto live-stream fallback when provider formats are unknown', () => { + streamFormat.set(StreamFormat.AutoStreamFormat); + + const url = service.constructLiveUrl(credentials, 101); + + expect(url).toBe('http://demo.example/live/demo/secret/101.m3u8'); + }); + + it('keeps the manual live-stream format override when provider formats are unknown', () => { + streamFormat.set(StreamFormat.TsStreamFormat); + + const url = service.constructLiveUrl(credentials, 101); + + expect(url).toBe('http://demo.example/live/demo/secret/101.ts'); + }); + it('returns empty stream URLs instead of throwing for invalid stored server URLs', () => { const invalidCredentials: XtreamCredentials = { ...credentials, @@ -109,35 +156,157 @@ describe('XtreamUrlService', () => { }); it('detects the legacy catchup scheme once and then uses the cached result', async () => { + const tsOnlyCredentials: XtreamCredentials = { + ...credentials, + allowedOutputFormats: ['ts'], + }; const xtreamProbeUrl = jest .fn() .mockResolvedValueOnce({ status: 404 }) - .mockResolvedValueOnce({ status: 302 }); + .mockResolvedValueOnce({ status: 206 }); window.electron = { xtreamProbeUrl, } as typeof window.electron; const firstUrl = await service.resolveCatchupUrl( 'playlist-1', - credentials, + tsOnlyCredentials, 101, 1775296800, 1775300400 ); const secondUrl = await service.resolveCatchupUrl( 'playlist-1', - credentials, + tsOnlyCredentials, 101, 1775296800, 1775300400 ); expect(firstUrl).toContain('/streaming/timeshift.php?'); + expect(firstUrl).toContain('extension=ts'); expect(secondUrl).toBe(firstUrl); expect(xtreamProbeUrl).toHaveBeenCalledTimes(2); expect(databaseService.setAppState).toHaveBeenCalledWith( - 'xtream-catchup-scheme:playlist-1', - 'legacy' + 'xtream-catchup-variant:v4:playlist-1:formats:ts', + 'legacy:ts' + ); + }); + + it('redetects catchup variants when provider output formats become known', async () => { + const xtreamProbeUrl = jest.fn(async (url: string) => ({ + status: + url.includes('/streaming/timeshift.php?') && + url.includes('extension=ts') + ? 206 + : url.includes('/streaming/timeshift.php?') && + url.includes('extension=m3u8') + ? 200 + : 0, + })); + window.electron = { + xtreamProbeUrl, + } as typeof window.electron; + + const initialUrl = await service.resolveCatchupUrl( + 'playlist-format-refresh', + credentials, + 101, + 1775296800, + 1775300400 + ); + const refreshedUrl = await service.resolveCatchupUrl( + 'playlist-format-refresh', + { + ...credentials, + allowedOutputFormats: ['m3u8'], + }, + 101, + 1775296800, + 1775300400 + ); + + expect(initialUrl).toContain('extension=ts'); + expect(refreshedUrl).toContain('extension=m3u8'); + expect(xtreamProbeUrl).toHaveBeenCalledTimes(4); + expect(databaseService.setAppState).toHaveBeenCalledWith( + 'xtream-catchup-variant:v4:playlist-format-refresh:formats:unknown', + 'legacy:ts' + ); + expect(databaseService.setAppState).toHaveBeenCalledWith( + 'xtream-catchup-variant:v4:playlist-format-refresh:formats:m3u8', + 'legacy:m3u8' + ); + }); + + it('prefers playable legacy MPEG-TS catchup before HLS for video.js compatible playlists', async () => { + const xtreamProbeUrl = jest.fn(async (url: string) => ({ + status: + url.includes('/streaming/timeshift.php?') && + url.includes('extension=ts') + ? 206 + : 0, + })); + window.electron = { + xtreamProbeUrl, + } as typeof window.electron; + + const catchupUrl = await service.resolveCatchupUrl( + 'playlist-hls', + { + ...credentials, + allowedOutputFormats: ['m3u8', 'ts'], + }, + 45, + 1782532800, + 1782534600, + 'UTC' + ); + + expect(catchupUrl).toContain('/streaming/timeshift.php?'); + expect(catchupUrl).toContain('stream=45'); + expect(catchupUrl).toContain('start=2026-06-27%3A04-00'); + expect(catchupUrl).toContain('duration=30'); + expect(catchupUrl).toContain('extension=ts'); + expect(xtreamProbeUrl).toHaveBeenCalledWith( + expect.stringContaining('/timeshift/'), + 'GET' + ); + expect(databaseService.setAppState).toHaveBeenCalledWith( + 'xtream-catchup-variant:v4:playlist-hls:formats:m3u8,ts', + 'legacy:ts' + ); + }); + + it('falls back to legacy HLS catchup when MPEG-TS probes fail', async () => { + const xtreamProbeUrl = jest.fn(async (url: string) => ({ + status: + url.includes('/streaming/timeshift.php?') && + url.includes('extension=m3u8') + ? 200 + : 0, + })); + window.electron = { + xtreamProbeUrl, + } as typeof window.electron; + + const catchupUrl = await service.resolveCatchupUrl( + 'playlist-hls-only', + { + ...credentials, + allowedOutputFormats: ['m3u8', 'ts'], + }, + 45, + 1782532800, + 1782534600, + 'UTC' + ); + + expect(catchupUrl).toContain('/streaming/timeshift.php?'); + expect(catchupUrl).toContain('extension=m3u8'); + expect(databaseService.setAppState).toHaveBeenCalledWith( + 'xtream-catchup-variant:v4:playlist-hls-only:formats:m3u8,ts', + 'legacy:m3u8' ); }); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index 0ec6014db..8aa9c49b3 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -1,6 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { normalizeXtreamServerUrl, + StreamFormat, XtreamSerieEpisode, XtreamVodDetails, } from '@iptvnator/shared/interfaces'; @@ -31,7 +32,32 @@ type XtreamVodStreamLike = XtreamVodDetails & { readonly stream_id?: number; }; -type XtreamCatchupScheme = 'rest' | 'legacy'; +const XTREAM_CATCHUP_SCHEME = { + LEGACY: 'legacy', + REST: 'rest', +} as const; + +type XtreamCatchupScheme = + (typeof XTREAM_CATCHUP_SCHEME)[keyof typeof XTREAM_CATCHUP_SCHEME]; + +const XTREAM_CATCHUP_VARIANT = { + LEGACY: 'legacy', + LEGACY_M3U8: 'legacy:m3u8', + LEGACY_TS: 'legacy:ts', + REST_M3U8: 'rest:m3u8', + REST_TS: 'rest:ts', +} as const; + +type XtreamCatchupVariant = + (typeof XTREAM_CATCHUP_VARIANT)[keyof typeof XTREAM_CATCHUP_VARIANT]; + +const XTREAM_CATCHUP_EXTENSIONS = { + M3U8: 'm3u8', + TS: 'ts', +} as const; + +type XtreamCatchupExtension = + (typeof XTREAM_CATCHUP_EXTENSIONS)[keyof typeof XTREAM_CATCHUP_EXTENSIONS]; interface NormalizedXtreamCredentials { password: string; @@ -48,7 +74,7 @@ type XtreamProbeApi = { ) => Promise<{ status: number }>; }; -const XTREAM_CATCHUP_SCHEME_KEY_PREFIX = 'xtream-catchup-scheme:'; +const XTREAM_CATCHUP_VARIANT_KEY_PREFIX = 'xtream-catchup-variant:v4:'; /** * Service for constructing Xtream stream URLs. @@ -60,11 +86,11 @@ export class XtreamUrlService { private readonly settingsStore = inject(SettingsStore); private readonly catchupSchemeCache = new Map< string, - XtreamCatchupScheme + XtreamCatchupVariant >(); private readonly catchupSchemeRequests = new Map< string, - Promise + Promise >(); /** @@ -83,7 +109,9 @@ export class XtreamUrlService { const streamFormat = this.resolveLiveStreamFormat( credentials, - format ?? this.settingsStore.streamFormat() ?? 'ts' + format ?? + this.settingsStore.streamFormat() ?? + StreamFormat.AutoStreamFormat ); return `${normalizedCredentials.serverUrl}/live/${normalizedCredentials.username}/${normalizedCredentials.password}/${xtreamId}.${streamFormat}`; } @@ -131,7 +159,7 @@ export class XtreamUrlService { streamId: number, startTimestamp: number, stopTimestamp: number, - scheme: XtreamCatchupScheme, + scheme: XtreamCatchupScheme | XtreamCatchupVariant, serverTimezone?: string ): string { const normalizedCredentials = this.normalizeCredentials(credentials); @@ -147,8 +175,10 @@ export class XtreamUrlService { startTimestamp, serverTimezone ); + const variant = this.normalizeCatchupVariant(scheme); + const extension = this.getCatchupVariantExtension(variant); - if (scheme === 'legacy') { + if (variant.startsWith(XTREAM_CATCHUP_SCHEME.LEGACY)) { const params = new URLSearchParams({ username: normalizedCredentials.rawUsername, password: normalizedCredentials.rawPassword, @@ -156,10 +186,13 @@ export class XtreamUrlService { start: timeString, duration: String(durationMinutes), }); + if (extension) { + params.set('extension', extension); + } return `${normalizedCredentials.serverUrl}/streaming/timeshift.php?${params.toString()}`; } - return `${normalizedCredentials.serverUrl}/timeshift/${normalizedCredentials.username}/${normalizedCredentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`; + return `${normalizedCredentials.serverUrl}/timeshift/${normalizedCredentials.username}/${normalizedCredentials.password}/${durationMinutes}/${timeString}/${streamId}.${extension ?? XTREAM_CATCHUP_EXTENSIONS.TS}`; } async resolveCatchupUrl( @@ -170,7 +203,7 @@ export class XtreamUrlService { stopTimestamp: number, serverTimezone?: string ): Promise { - const scheme = await this.getCatchupScheme( + const variant = await this.getCatchupVariant( playlistId, credentials, streamId, @@ -184,29 +217,33 @@ export class XtreamUrlService { streamId, startTimestamp, stopTimestamp, - scheme, + variant, serverTimezone ); } - private async getCatchupScheme( + private async getCatchupVariant( playlistId: string, credentials: XtreamCredentials, streamId: number, startTimestamp: number, stopTimestamp: number, serverTimezone?: string - ): Promise { - const cacheKey = `${XTREAM_CATCHUP_SCHEME_KEY_PREFIX}${playlistId}`; + ): Promise { + const cacheKey = this.getCatchupVariantCacheKey( + playlistId, + credentials + ); const cached = this.catchupSchemeCache.get(cacheKey); if (cached) { - return cached; + return this.normalizeCatchupVariant(cached); } const persisted = await this.databaseService.getAppState(cacheKey); - if (persisted === 'rest' || persisted === 'legacy') { - this.catchupSchemeCache.set(cacheKey, persisted); - return persisted; + const persistedVariant = this.parseCatchupVariant(persisted); + if (persistedVariant) { + this.catchupSchemeCache.set(cacheKey, persistedVariant); + return persistedVariant; } const inFlightRequest = this.catchupSchemeRequests.get(cacheKey); @@ -214,7 +251,7 @@ export class XtreamUrlService { return inFlightRequest; } - const request = this.detectCatchupScheme( + const request = this.detectCatchupVariant( cacheKey, credentials, streamId, @@ -229,52 +266,41 @@ export class XtreamUrlService { return request; } - private async detectCatchupScheme( + private async detectCatchupVariant( cacheKey: string, credentials: XtreamCredentials, streamId: number, startTimestamp: number, stopTimestamp: number, serverTimezone?: string - ): Promise { - const restUrl = this.constructCatchupUrl( - credentials, - streamId, - startTimestamp, - stopTimestamp, - 'rest', - serverTimezone - ); - const legacyUrl = this.constructCatchupUrl( - credentials, - streamId, - startTimestamp, - stopTimestamp, - 'legacy', - serverTimezone - ); + ): Promise { + for (const variant of this.getCatchupVariantCandidates(credentials)) { + const url = this.constructCatchupUrl( + credentials, + streamId, + startTimestamp, + stopTimestamp, + variant, + serverTimezone + ); + if (!url) { + continue; + } - if (!restUrl || !legacyUrl) { - return 'rest'; + const status = await this.probeCatchupUrl(url); + if (this.isAcceptedCatchupProbeStatus(status)) { + this.catchupSchemeCache.set(cacheKey, variant); + await this.databaseService.setAppState(cacheKey, variant); + return variant; + } } - const restStatus = await this.probeCatchupUrl(restUrl); - let detectedScheme: XtreamCatchupScheme; - - if (this.isAcceptedCatchupProbeStatus(restStatus)) { - detectedScheme = 'rest'; - } else { - const legacyStatus = await this.probeCatchupUrl(legacyUrl); - detectedScheme = this.isAcceptedCatchupProbeStatus(legacyStatus) - ? 'legacy' - : restStatus === 404 - ? 'legacy' - : 'rest'; - } - - this.catchupSchemeCache.set(cacheKey, detectedScheme); - await this.databaseService.setAppState(cacheKey, detectedScheme); - return detectedScheme; + this.catchupSchemeCache.set(cacheKey, XTREAM_CATCHUP_VARIANT.REST_TS); + await this.databaseService.setAppState( + cacheKey, + XTREAM_CATCHUP_VARIANT.REST_TS + ); + return XTREAM_CATCHUP_VARIANT.REST_TS; } private async probeCatchupUrl(url: string): Promise { @@ -286,7 +312,7 @@ export class XtreamUrlService { } try { - const result = await probeUrl(url, 'HEAD'); + const result = await probeUrl(url, 'GET'); return Number(result?.status ?? 0); } catch { return 0; @@ -294,14 +320,123 @@ export class XtreamUrlService { } private isAcceptedCatchupProbeStatus(status: number): boolean { + return status === 200 || status === 206; + } + + private getCatchupVariantCandidates( + credentials: XtreamCredentials + ): XtreamCatchupVariant[] { + const variants: XtreamCatchupVariant[] = []; + + for (const extension of this.getPreferredCatchupExtensions( + credentials + )) { + variants.push( + extension === XTREAM_CATCHUP_EXTENSIONS.M3U8 + ? XTREAM_CATCHUP_VARIANT.REST_M3U8 + : XTREAM_CATCHUP_VARIANT.REST_TS + ); + variants.push( + extension === XTREAM_CATCHUP_EXTENSIONS.M3U8 + ? XTREAM_CATCHUP_VARIANT.LEGACY_M3U8 + : XTREAM_CATCHUP_VARIANT.LEGACY_TS + ); + } + + variants.push(XTREAM_CATCHUP_VARIANT.LEGACY); + + return [...new Set(variants)]; + } + + private getPreferredCatchupExtensions( + credentials: XtreamCredentials + ): XtreamCatchupExtension[] { + const allowedFormats = + this.getNormalizedAllowedOutputFormats(credentials); + const formats = + allowedFormats && allowedFormats.length > 0 + ? allowedFormats + : [ + XTREAM_CATCHUP_EXTENSIONS.M3U8, + XTREAM_CATCHUP_EXTENSIONS.TS, + ]; + + const preferred = [ + XTREAM_CATCHUP_EXTENSIONS.TS, + XTREAM_CATCHUP_EXTENSIONS.M3U8, + ].filter((format) => formats.includes(format)); + + return preferred.length > 0 + ? preferred + : [XTREAM_CATCHUP_EXTENSIONS.TS]; + } + + private getCatchupVariantCacheKey( + playlistId: string, + credentials: XtreamCredentials + ): string { + const allowedFormats = + this.getNormalizedAllowedOutputFormats(credentials); + const formatSignature = + allowedFormats && allowedFormats.length > 0 + ? [...new Set(allowedFormats)] + .sort() + .map((format) => encodeURIComponent(format)) + .join(',') + : 'unknown'; + + return `${XTREAM_CATCHUP_VARIANT_KEY_PREFIX}${playlistId}:formats:${formatSignature}`; + } + + private getNormalizedAllowedOutputFormats( + credentials: XtreamCredentials + ): string[] | undefined { + const allowedFormats = credentials.allowedOutputFormats + ?.map((format) => format.trim().toLowerCase()) + .filter(Boolean); + + return allowedFormats && allowedFormats.length > 0 + ? allowedFormats + : undefined; + } + + private normalizeCatchupVariant( + scheme: XtreamCatchupScheme | XtreamCatchupVariant + ): XtreamCatchupVariant { return ( - (status >= 200 && status < 400) || - status === 401 || - status === 403 || - status === 405 + this.parseCatchupVariant(scheme) ?? XTREAM_CATCHUP_VARIANT.REST_TS ); } + private parseCatchupVariant( + value: string | null + ): XtreamCatchupVariant | null { + const variants = Object.values(XTREAM_CATCHUP_VARIANT); + return variants.includes(value as XtreamCatchupVariant) + ? (value as XtreamCatchupVariant) + : null; + } + + private getCatchupVariantExtension( + variant: XtreamCatchupVariant + ): XtreamCatchupExtension | null { + if ( + variant === XTREAM_CATCHUP_VARIANT.REST_M3U8 || + variant === XTREAM_CATCHUP_VARIANT.LEGACY_M3U8 + ) { + return XTREAM_CATCHUP_EXTENSIONS.M3U8; + } + + if ( + variant === XTREAM_CATCHUP_VARIANT.REST_TS || + variant === XTREAM_CATCHUP_VARIANT.LEGACY_TS + ) { + return XTREAM_CATCHUP_EXTENSIONS.TS; + } + + return null; + } + private normalizeCredentials( credentials: XtreamCredentials ): NormalizedXtreamCredentials | null { @@ -331,10 +466,13 @@ export class XtreamUrlService { credentials: XtreamCredentials, requestedFormat: string ): string { - const requested = requestedFormat.trim(); - const allowedFormats = credentials.allowedOutputFormats - ?.map((format) => format.trim()) - .filter(Boolean); + const requested = requestedFormat.trim().toLowerCase(); + const allowedFormats = + this.getNormalizedAllowedOutputFormats(credentials); + + if (!requested || requested === StreamFormat.AutoStreamFormat) { + return this.resolveAutoLiveStreamFormat(allowedFormats); + } if (allowedFormats?.length && !allowedFormats.includes(requested)) { return allowedFormats[0]; @@ -343,6 +481,24 @@ export class XtreamUrlService { return requested; } + private resolveAutoLiveStreamFormat( + allowedFormats: string[] | undefined + ): string { + if (!allowedFormats?.length) { + return StreamFormat.M3u8StreamFormat; + } + + if (allowedFormats.includes(StreamFormat.M3u8StreamFormat)) { + return StreamFormat.M3u8StreamFormat; + } + + if (allowedFormats.includes(StreamFormat.TsStreamFormat)) { + return StreamFormat.TsStreamFormat; + } + + return allowedFormats[0]; + } + private formatCatchupStartTime( timestamp: number, timezone?: string diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 204ded13b..7819c4f66 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -117,12 +117,15 @@ [collapsed]="isLiveEpgPanelCollapsed()" [summary]="liveEpgPanelSummary()" [loading]="isLoadingEpg()" + [summaryLabelKey]="liveEpgPanelSummaryLabelKey()" [showDateNavigator]="true" [selectedDate]="selectedLiveEpgDate()" + [showReturnToLive]="showReturnToLive()" (collapsedChange)=" onLiveEpgPanelCollapsedChange($event) " (dateNavigation)="onLiveEpgDateNavigation($event)" + (returnToLive)="returnToLivePlayback()" > (null); readonly controlledArchiveDays = input(null); readonly archivePlaybackAvailable = input(null); + readonly activeProgram = input(null); readonly selectedDate = input(null); readonly showDateNavigator = input(true); readonly programActivated = output(); @@ -112,7 +113,16 @@ class StubEpgListComponent { selector: 'app-live-epg-panel', standalone: true, template: ` +
{{ summaryLabelKey() }}
{{ summary()?.title }}
+ `, }) @@ -120,10 +130,13 @@ class StubLiveEpgPanelComponent { readonly collapsed = input(false); readonly summary = input(null); readonly loading = input(false); + readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM'); readonly showDateNavigator = input(false); readonly selectedDate = input(null); + readonly showReturnToLive = input(false); readonly collapsedChange = output(); readonly dateNavigation = output<'next' | 'prev'>(); + readonly returnToLive = output(); } @Directive({ @@ -684,6 +697,10 @@ describe('LiveStreamLayoutComponent', () => { fixture.nativeElement.querySelector('.live-epg-panel-summary') .textContent ).toContain('Current Show'); + expect( + fixture.nativeElement.querySelector('.live-epg-panel-label') + .textContent + ).toContain('EPG.CURRENT_PROGRAM'); }); it('uses currentEpgItem instead of assuming the first schedule item is current', () => { @@ -746,6 +763,7 @@ describe('LiveStreamLayoutComponent', () => { expect(xtreamUrlService.resolveCatchupUrl).toHaveBeenCalledWith( 'playlist-1', { + allowedOutputFormats: undefined, serverUrl: 'http://demo.example', username: 'demo', password: 'secret', @@ -760,6 +778,199 @@ describe('LiveStreamLayoutComponent', () => { 'Channel 101 - Archived Show', 'channel-101.png' ); + expect(component.activePlayback()).toEqual( + expect.objectContaining({ + streamUrl: 'https://example.com/timeshift.ts', + isLive: false, + }) + ); + }); + + it('shows the active archive program in the live EPG panel summary', async () => { + const archivedProgram: EpgProgram = { + start: '2026-04-04T10:00:00.000Z', + stop: '2026-04-04T11:00:00.000Z', + channel: 'channel-101', + title: 'Archived Show', + desc: null, + category: null, + startTimestamp: 1775296800, + stopTimestamp: 1775300400, + }; + epgItems.set([ + buildEpgItem( + 'archived', + 'Archived Show', + archivedProgram.start, + archivedProgram.stop + ), + ]); + currentEpgItem.set( + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ) + ); + + component.playLive(sampleChannel); + await component.onProgramActivated({ + type: 'timeshift', + program: archivedProgram, + }); + fixture.detectChanges(); + + const panel = fixture.debugElement.query( + By.directive(StubLiveEpgPanelComponent) + ); + expect(panel.componentInstance.summary()).toEqual( + expect.objectContaining({ + title: 'Archived Show', + start: '2026-04-04T10:00:00.000Z', + stop: '2026-04-04T11:00:00.000Z', + }) + ); + expect(panel.componentInstance.summaryLabelKey()).toBe( + 'EPG.ARCHIVE_PLAYBACK' + ); + expect(panel.componentInstance.showReturnToLive()).toBe(true); + expect( + fixture.nativeElement.querySelector('.live-epg-panel-summary') + .textContent + ).toContain('Archived Show'); + expect( + fixture.nativeElement.querySelector('.live-epg-panel-summary') + .textContent + ).not.toContain('Current Show'); + }); + + it('returns archive playback to the selected live stream from the panel action', async () => { + const archivedProgram: EpgProgram = { + start: '2026-04-04T10:00:00.000Z', + stop: '2026-04-04T11:00:00.000Z', + channel: 'channel-101', + title: 'Archived Show', + desc: null, + category: null, + startTimestamp: 1775296800, + stopTimestamp: 1775300400, + }; + currentEpgItem.set( + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ) + ); + + component.playLive(sampleChannel); + await component.onProgramActivated({ + type: 'timeshift', + program: archivedProgram, + }); + fixture.detectChanges(); + + const panel = fixture.debugElement.query( + By.directive(StubLiveEpgPanelComponent) + ); + panel.componentInstance.returnToLive.emit(); + fixture.detectChanges(); + + expect(component.activeCatchupProgram()).toBeNull(); + expect(component.activePlayback()).toEqual( + expect.objectContaining({ + streamUrl: 'https://example.com/live.ts', + isLive: true, + }) + ); + expect(panel.componentInstance.summary()?.title).toBe('Current Show'); + expect(panel.componentInstance.summaryLabelKey()).toBe( + 'EPG.CURRENT_PROGRAM' + ); + expect(panel.componentInstance.showReturnToLive()).toBe(false); + }); + + it('publishes the active archive program in remote-control status', async () => { + const archivedProgram: EpgProgram = { + start: '2026-04-04T10:00:00.000Z', + stop: '2026-04-04T11:00:00.000Z', + channel: 'channel-101', + title: 'Archived Show', + desc: null, + category: null, + startTimestamp: 1775296800, + stopTimestamp: 1775300400, + }; + currentEpgItem.set( + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ) + ); + const updateRemoteControlStatus = window.electron + ?.updateRemoteControlStatus as jest.Mock; + + component.playLive(sampleChannel); + fixture.detectChanges(); + updateRemoteControlStatus.mockClear(); + + await component.onProgramActivated({ + type: 'timeshift', + program: archivedProgram, + }); + fixture.detectChanges(); + + expect(updateRemoteControlStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ + epgTitle: 'Archived Show', + epgStart: '2026-04-04T10:00:00.000Z', + epgEnd: '2026-04-04T11:00:00.000Z', + }) + ); + }); + + it('passes the active catchup program to the EPG list until live playback resumes', async () => { + const archivedProgram: EpgProgram = { + start: '2026-04-04T10:00:00.000Z', + stop: '2026-04-04T11:00:00.000Z', + channel: 'channel-101', + title: 'Archived Show', + desc: null, + category: null, + startTimestamp: 1775296800, + stopTimestamp: 1775300400, + }; + epgItems.set([ + buildEpgItem( + '1', + 'Archived Show', + archivedProgram.start, + archivedProgram.stop + ), + ]); + + await component.onProgramActivated({ + type: 'timeshift', + program: archivedProgram, + }); + fixture.detectChanges(); + + let epgList = fixture.debugElement.query( + By.directive(StubEpgListComponent) + ); + expect(epgList.componentInstance.activeProgram()).toEqual( + archivedProgram + ); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + epgList = fixture.debugElement.query(By.directive(StubEpgListComponent)); + expect(epgList.componentInstance.activeProgram()).toBeNull(); }); it('starts external playback from remote channel navigation when double-click opening is enabled', () => { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 3ae1499c9..a28d14b54 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -177,6 +177,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.epgItems().map((program) => this.toControlledEpgProgram(program)) ); private readonly currentTimeMs = signal(Date.now()); + readonly activeCatchupProgram = signal(null); readonly controlledArchiveDays = computed(() => Math.max( 0, @@ -215,7 +216,17 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { ); readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly liveEpgPanelSummary = computed(() => - this.toLiveEpgPanelSummary(this.currentEpgItem()) + this.toLiveEpgPanelSummary( + this.activeCatchupProgram() ?? this.currentEpgItem() + ) + ); + readonly liveEpgPanelSummaryLabelKey = computed(() => + this.activeCatchupProgram() + ? 'EPG.ARCHIVE_PLAYBACK' + : 'EPG.CURRENT_PROGRAM' + ); + readonly showReturnToLive = computed( + () => this.activeCatchupProgram() !== null ); readonly liveChannelSortLabel = computed(() => getPortalChannelSortModeLabel(this.liveChannelSortMode()) @@ -352,7 +363,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { const selectedContentType = this.xtreamStore.selectedContentType(); const selectedItem = this.xtreamStore.selectedItem(); const channels = this.getVisibleChannels(); - const currentProgram = this.currentEpgItem(); + const activeProgram = + this.activeCatchupProgram() ?? this.currentEpgItem(); if (selectedContentType !== 'live' || !selectedItem?.xtream_id) { remoteControl.updateRemoteControlStatus({ @@ -373,9 +385,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { isLiveView: true, channelName: selectedItem.title ?? selectedItem.name, channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined, - epgTitle: currentProgram?.title, - epgStart: currentProgram?.start, - epgEnd: currentProgram?.stop ?? currentProgram?.end, + epgTitle: activeProgram?.title, + epgStart: activeProgram?.start, + epgEnd: this.getProgramStop(activeProgram), supportsVolume: false, }); }); @@ -426,6 +438,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { startPlayback = !this.settingsStore.openStreamOnDoubleClick() ) { const streamUrl = this.xtreamStore.constructStreamUrl(item); + this.activeCatchupProgram.set(null); // Keep both root/recently-added playback and same-category replays in // sync with the category rail. For already-selected channels this is a // store no-op. @@ -516,6 +529,15 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.selectedLiveEpgDate.set(selectedDate); } + returnToLivePlayback(): void { + const selectedItem = this.selectedLiveItem(); + if (!selectedItem?.xtream_id) { + return; + } + + this.playLive(selectedItem, true); + } + ngOnDestroy(): void { this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); @@ -622,6 +644,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl( playlist.id, { + allowedOutputFormats: playlist.allowedOutputFormats, serverUrl: playlist.serverUrl, username: playlist.username, password: playlist.password, @@ -632,10 +655,12 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { playlist.serverTimezone ); + this.activeCatchupProgram.set(program); this.activePlayback.set({ streamUrl: catchupUrl, title: this.getCatchupPlaybackTitle(item, program), thumbnail: item.poster_url ?? item.stream_icon ?? null, + isLive: false, }); if (this.usesEmbeddedPlayer()) { return; @@ -668,7 +693,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } private toLiveEpgPanelSummary( - program: EpgItem | null | undefined + program: EpgItem | EpgProgram | null | undefined ): LiveEpgPanelSummary | null { if (!program) { return null; @@ -677,10 +702,23 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return { title: program.title, start: program.start, - stop: program.stop ?? program.end, + stop: this.getProgramStop(program), }; } + private getProgramStop( + program: EpgItem | EpgProgram | null | undefined + ): string | undefined { + if (!program) { + return undefined; + } + + return ( + program.stop ?? + ('end' in program ? (program.end ?? undefined) : undefined) + ); + } + private get remoteControlBridge(): Window['electron'] | undefined { return this.runtime.supportsRemoteControl ? window.electron : undefined; } diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index e118efe44..b7793d487 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -57,6 +57,7 @@ describe('SettingsStore dashboard rail settings', () => { await store.loadSettings(); + expect(store.getSettings().streamFormat).toBe('auto'); expect(store.getSettings().dashboardRails).toEqual( expectedDashboardRails() ); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 22764b660..c5693c0b3 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -23,7 +23,7 @@ import { const DEFAULT_SETTINGS: Settings = { player: VideoPlayer.VideoJs, - streamFormat: StreamFormat.M3u8StreamFormat, + streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, showCaptions: false, diff --git a/libs/shared/interfaces/src/lib/stream-format.enum.ts b/libs/shared/interfaces/src/lib/stream-format.enum.ts index f85789b8a..177006e19 100644 --- a/libs/shared/interfaces/src/lib/stream-format.enum.ts +++ b/libs/shared/interfaces/src/lib/stream-format.enum.ts @@ -2,6 +2,7 @@ * Enum with all the formats that are allowed in the application */ export enum StreamFormat { + AutoStreamFormat = 'auto', TsStreamFormat = 'ts', M3u8StreamFormat = 'm3u8', } diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html index b6bbc3955..147733ab4 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html @@ -10,6 +10,7 @@ @if (showArchiveBadge) {