Merge pull request #1094 from 4gray/agent/timeshift-epg-handling

Improve Xtream timeshift and stream format handling
This commit is contained in:
4gray authored and GitHub committed 2026-06-27 13:25:41 +02:00
commit 769f80f00c
49 files changed
+1324 -131

No files matched your search

@@ -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<readonly string[]>;
}
@@ -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'
@@ -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<string, string>;
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);
@@ -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,
@@ -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<string | LookupAddress[]>((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({
@@ -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<T = unknown>(
((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<T>({
...pinnedConfig,
+5 -2
View File
@@ -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,
@@ -32,7 +32,7 @@ export function createSettingsForm(
...(supportsEpg
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
: {}),
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,
@@ -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(''),
@@ -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,
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+9
View File
@@ -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
@@ -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.
@@ -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<Promise<string | null>, [string]>;
setAppState: jest.Mock<Promise<void>, [string, string]>;
};
let streamFormat: WritableSignal<StreamFormat>;
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'
);
});
@@ -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<XtreamCatchupScheme>
Promise<XtreamCatchupVariant>
>();
/**
@@ -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<string> {
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<XtreamCatchupScheme> {
const cacheKey = `${XTREAM_CATCHUP_SCHEME_KEY_PREFIX}${playlistId}`;
): Promise<XtreamCatchupVariant> {
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<XtreamCatchupScheme> {
const restUrl = this.constructCatchupUrl(
credentials,
streamId,
startTimestamp,
stopTimestamp,
'rest',
serverTimezone
);
const legacyUrl = this.constructCatchupUrl(
credentials,
streamId,
startTimestamp,
stopTimestamp,
'legacy',
serverTimezone
);
): Promise<XtreamCatchupVariant> {
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<number> {
@@ -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
@@ -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()"
>
<ng-container
*ngTemplateOutlet="
@@ -221,6 +224,7 @@
[controlledPrograms]="controlledEpgPrograms()"
[controlledArchiveDays]="controlledArchiveDays()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[activeProgram]="activeCatchupProgram()"
[selectedDate]="
unifiedDateNavigator ? selectedLiveEpgDate() : null
"
@@ -102,6 +102,7 @@ class StubEpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly activeProgram = input<EpgProgram | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(true);
readonly programActivated = output<EpgProgramActivationEvent>();
@@ -112,7 +113,16 @@ class StubEpgListComponent {
selector: 'app-live-epg-panel',
standalone: true,
template: `
<div class="live-epg-panel-label">{{ summaryLabelKey() }}</div>
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
<button
class="live-epg-panel-return"
type="button"
[hidden]="!showReturnToLive()"
(click)="returnToLive.emit()"
>
Return to live
</button>
<ng-content />
`,
})
@@ -120,10 +130,13 @@ class StubLiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly showReturnToLive = input(false);
readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<'next' | 'prev'>();
readonly returnToLive = output<void>();
}
@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', () => {
@@ -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<EpgProgram | null>(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;
}
@@ -57,6 +57,7 @@ describe('SettingsStore dashboard rail settings', () => {
await store.loadSettings();
expect(store.getSettings().streamFormat).toBe('auto');
expect(store.getSettings().dashboardRails).toEqual(
expectedDashboardRails()
);
@@ -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,
@@ -2,6 +2,7 @@
* Enum with all the formats that are allowed in the application
*/
export enum StreamFormat {
AutoStreamFormat = 'auto',
TsStreamFormat = 'ts',
M3u8StreamFormat = 'm3u8',
}
@@ -10,6 +10,7 @@
@if (showArchiveBadge) {
<span
class="archive-playback badge"
[class.archive-playback--active]="isActive"
[matTooltip]="'EPG.TIMESHIFT_AVAILABLE' | translate"
>
<mat-icon class="archive-playback__icon" aria-hidden="true"
@@ -60,6 +60,19 @@
padding-inline: 7px 10px;
}
.archive-playback--active {
color: var(--app-selection-on-color);
background: var(--app-selection-color);
border-color: color-mix(
in srgb,
var(--app-selection-color) 88%,
transparent
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 10px 18px -14px var(--app-selection-glow);
}
.archive-playback__icon {
width: 14px;
height: 14px;
@@ -93,4 +93,17 @@ describe('EpgListItemComponent', () => {
expect(chip.textContent).toContain('Play');
expect(icon.textContent.trim()).toBe('replay');
});
it('marks the archive playback chip as active when the program is active', () => {
fixture = TestBed.createComponent(EpgListItemComponent);
component = fixture.componentInstance;
component.item = EPG_PROGRAM_ITEM;
component.showArchiveBadge = true;
component.isActive = true;
fixture.detectChanges();
const chip = fixture.nativeElement.querySelector('.archive-playback');
expect(chip.classList).toContain('archive-playback--active');
});
});
@@ -22,6 +22,9 @@ export class EpgListItemComponent {
/** Whether the program is currently live */
@Input() isLive = false;
/** Whether the program is the active playback target */
@Input() isActive = false;
/** Whether archive playback is available for the program */
@Input() showArchiveBadge = false;
@@ -45,6 +45,7 @@
<app-epg-list-item
[item]="program"
[isLive]="isProgramPlaying(program)"
[isActive]="isProgramActive(program)"
[showArchiveBadge]="
canPlayArchivedProgram(program)
"
@@ -73,13 +74,23 @@
<div
class="program-item clickable"
[class.current-program]="isProgramPlaying(program)"
[class.active]="isProgramPlaying(program)"
[class.archive-program]="
canPlayArchivedProgram(program)
"
[class.active]="isProgramActive(program)"
role="button"
tabindex="0"
(click)="activateProgram(program)"
(keydown.enter)="activateProgram(program)"
(keydown.space)="
activateProgramFromKeyboard($event, program)
"
>
<div class="program-meta">
<app-epg-list-item
[item]="program"
[isLive]="isProgramPlaying(program)"
[isActive]="isProgramActive(program)"
[showArchiveBadge]="
canPlayArchivedProgram(program)
"
@@ -56,6 +56,7 @@
}
.program-item {
position: relative;
display: flex;
flex-direction: column;
gap: 5px;
@@ -117,6 +118,47 @@
transparent
);
}
&:focus-visible {
outline: 2px solid var(--app-selection-border);
outline-offset: 2px;
background: color-mix(
in srgb,
var(--app-selection-color) 8%,
transparent
);
&::before,
& + .program-item::before {
display: none;
}
}
}
&.archive-program {
&::after {
content: '';
position: absolute;
inset-block: 12px;
inset-inline-start: 0;
width: 3px;
border-radius: 999px;
background: var(--app-selection-color);
opacity: 0.48;
transform: scaleY(0.68);
transition:
opacity 0.15s ease,
transform 0.15s ease,
box-shadow 0.15s ease;
}
&:hover::after,
&:focus-visible::after,
&.active::after {
opacity: 1;
transform: scaleY(1);
box-shadow: 0 0 12px -4px var(--app-selection-glow);
}
}
&.active {
@@ -24,6 +24,7 @@ registerLocaleData(localeDe, 'de');
class StubEpgListItemComponent {
readonly item = input<EpgProgram>();
readonly isLive = input(false);
readonly isActive = input(false);
readonly showArchiveBadge = input(false);
}
@@ -229,6 +230,42 @@ describe('EpgListComponent', () => {
).toBe(2);
});
it('highlights an externally active archived program without marking the live program active', () => {
const programs = buildPrograms();
fixture.componentRef.setInput('controlledPrograms', programs);
fixture.componentRef.setInput('archivePlaybackAvailable', true);
fixture.componentRef.setInput('activeProgram', programs[0]);
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.program-item');
expect(rows[0].classList).toContain('active');
expect(rows[1].classList).toContain('current-program');
expect(rows[1].classList).not.toContain('active');
});
it('exposes playable rows as keyboard-activatable buttons', () => {
const programs = buildPrograms();
const emitted: unknown[] = [];
component.programActivated.subscribe((event) => emitted.push(event));
fixture.componentRef.setInput('controlledPrograms', programs);
fixture.componentRef.setInput('archivePlaybackAvailable', true);
fixture.detectChanges();
const archivedRow = fixture.nativeElement.querySelector(
'.program-item.clickable'
) as HTMLElement;
archivedRow.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
);
expect(archivedRow.getAttribute('role')).toBe('button');
expect(archivedRow.getAttribute('tabindex')).toBe('0');
expect(emitted).toEqual([{ program: programs[0], type: 'timeshift' }]);
});
it('updates the selected-day header when the app language changes', () => {
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
fixture.detectChanges();
@@ -55,6 +55,7 @@ export class EpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly activeProgram = input<EpgProgram | null>(null);
readonly selectedDateInput = input<string | null>(null, {
alias: 'selectedDate',
});
@@ -235,6 +236,11 @@ export class EpgListComponent {
this.timeNow.set(new Date().toISOString());
}
activateProgramFromKeyboard(event: Event, program: EpgProgram): void {
event.preventDefault();
this.activateProgram(program);
}
canActivateProgram(program: EpgProgram): boolean {
return (
this.isProgramPlaying(program) ||
@@ -278,6 +284,13 @@ export class EpgListComponent {
return stop < now && start >= archiveLimit;
}
isProgramActive(program: EpgProgram): boolean {
const activeProgram = this.activeProgram();
return activeProgram
? areProgramsSame(program, activeProgram)
: this.isProgramPlaying(program);
}
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | undefined {
const now = this.currentTimeMs();
return programs.find((program) => {
@@ -402,3 +415,13 @@ function getProgramDateKey(
return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
}
function areProgramsSame(left: EpgProgram, right: EpgProgram): boolean {
return (
(left.channel ?? '') === (right.channel ?? '') &&
getProgramTimeMs(left.start, left.startTimestamp) ===
getProgramTimeMs(right.start, right.startTimestamp) &&
getProgramTimeMs(left.stop, left.stopTimestamp) ===
getProgramTimeMs(right.stop, right.stopTimestamp)
);
}
@@ -22,7 +22,7 @@
<div class="live-epg-panel__summary" role="status">
<span class="live-epg-panel__label">
{{ 'EPG.CURRENT_PROGRAM' | translate }}
{{ summaryLabelKey() | translate }}
</span>
<div class="live-epg-panel__program-line">
@@ -58,6 +58,22 @@
</div>
</div>
@if (showReturnToLive()) {
<button
mat-button
class="live-epg-panel__return-live"
type="button"
(click)="returnToLive.emit()"
[attr.aria-label]="'EPG.RETURN_TO_LIVE' | translate"
[matTooltip]="'EPG.RETURN_TO_LIVE' | translate"
>
<mat-icon>live_tv</mat-icon>
<span class="live-epg-panel__return-live-text">
{{ 'EPG.RETURN_TO_LIVE' | translate }}
</span>
</button>
}
@if (showDateNavigator()) {
@if (!collapsed()) {
<div
@@ -16,7 +16,7 @@
.live-epg-panel__summary-bar {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
column-gap: 8px;
min-height: 56px;
@@ -95,6 +95,45 @@
color: var(--mat-sys-on-surface-variant);
}
.live-epg-panel__return-live {
min-width: 0;
height: 36px;
padding-inline: 10px 12px;
border-radius: 10px;
color: var(--app-selection-color);
background: color-mix(
in srgb,
var(--app-selection-color) 10%,
transparent
);
app-region: no-drag;
mat-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
font-size: 18px;
}
.live-epg-panel__return-live-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
font-weight: 700;
}
&:hover {
color: var(--mat-sys-on-primary-container);
background: color-mix(
in srgb,
var(--app-selection-color) 16%,
transparent
);
}
}
.live-epg-panel__date-nav {
display: flex;
align-items: center;
@@ -230,6 +269,17 @@
}
}
@media only screen and (max-width: 640px) {
.live-epg-panel__return-live {
width: 36px;
padding-inline: 0;
.live-epg-panel__return-live-text {
display: none;
}
}
}
@media only screen and (max-width: 560px) {
.live-epg-panel__label {
display: none;
@@ -15,10 +15,13 @@ import {
[collapsed]="collapsed"
[summary]="summary"
[loading]="loading"
[summaryLabelKey]="summaryLabelKey"
[showDateNavigator]="showDateNavigator"
[selectedDate]="selectedDate"
[showReturnToLive]="showReturnToLive"
(collapsedChange)="collapsed = $event"
(dateNavigation)="dateDirection = $event"
(returnToLive)="returnToLiveCount = returnToLiveCount + 1"
>
<div class="projected-content">Projected EPG</div>
</app-live-epg-panel>
@@ -27,9 +30,12 @@ import {
class HostComponent {
collapsed = false;
loading = false;
summaryLabelKey = 'EPG.CURRENT_PROGRAM';
showDateNavigator = false;
showReturnToLive = false;
selectedDate = '2026-04-05';
dateDirection: 'next' | 'prev' | null = null;
returnToLiveCount = 0;
summary: LiveEpgPanelSummary | null = {
title: 'Current Show',
start: '2026-04-05T11:30:00.000Z',
@@ -67,6 +73,10 @@ describe('LiveEpgPanelComponent', () => {
fixture.nativeElement.querySelector('.live-epg-panel__title')
.textContent
).toContain('Current Show');
expect(
fixture.nativeElement.querySelector('.live-epg-panel__label')
.textContent
).toContain('EPG.CURRENT_PROGRAM');
expect(
fixture.nativeElement.querySelector(
'.live-epg-panel__progress-fill'
@@ -139,4 +149,25 @@ describe('LiveEpgPanelComponent', () => {
fixture.nativeElement.querySelector('.live-epg-panel__date-nav')
).toBeNull();
});
it('renders archive playback state and emits return-to-live requests', () => {
fixture.componentInstance.summaryLabelKey = 'EPG.ARCHIVE_PLAYBACK';
fixture.componentInstance.showReturnToLive = true;
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.live-epg-panel__label')
.textContent
).toContain('EPG.ARCHIVE_PLAYBACK');
const returnButton = fixture.nativeElement.querySelector(
'.live-epg-panel__return-live'
) as HTMLButtonElement | null;
expect(returnButton).not.toBeNull();
expect(returnButton?.textContent).toContain('EPG.RETURN_TO_LIVE');
returnButton?.click();
expect(fixture.componentInstance.returnToLiveCount).toBe(1);
});
});
@@ -48,10 +48,13 @@ export class LiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly showReturnToLive = input(false);
readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<EpgDateNavigationDirection>();
readonly returnToLive = output<void>();
private readonly translate = inject(TranslateService);
private readonly currentTimeMs = signal(Date.now());