mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
Merge pull request #1094 from 4gray/agent/timeshift-epg-handling
Improve Xtream timeshift and stream format handling
This commit is contained in:
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
@@ -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
|
||||
"
|
||||
|
||||
+211
@@ -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', () => {
|
||||
|
||||
+45
-7
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user