fix(xtream): try advertised TS after initial web HLS HTTP failure (#1558)

* fix(xtream): try advertised TS after initial web HLS HTTP failure

* refactor(playback): extract fullscreen channel panel state

* test(xtream): keep synthetic media within the mock project
This commit is contained in:
4gray authored and GitHub committed 2026-09-06 11:00:09 +02:00
1 parent ef2418a2c5
commit 436825bdec
37 files changed
+1457 -67

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
import { expect, test } from './fixtures';
import { interceptProviderTargetRegistration } from './provider-target-route';
import {
addLiveFormatPortal,
configureLiveFormat,
expectLiveFormatPlaying,
liveChannels,
liveFormatMock,
observeLiveFormatMedia,
} from './xtream-live-format.fixture';
test.use({
launchOptions: { args: ['--autoplay-policy=no-user-gesture-required'] },
serviceWorkers: 'block',
});
test.skip(
({ browserName }) => browserName !== 'chromium',
'Chromium media engines'
);
test.beforeEach(async ({ page }) => {
test.setTimeout(100_000);
await interceptProviderTargetRegistration(page);
await page.route('**/localhost:3000/xtream**', async (route) => {
const url = new URL(route.request().url());
url.host = new URL(liveFormatMock).host;
await route.continue({ url: url.toString() });
});
await page.goto('/');
});
for (const player of ['html5', 'artplayer'] as const) {
for (const channel of [0, 1]) {
test(`@web @xtream Auto live format ${player}: ${channel ? 'manifest' : 'segment'} 403 then playable TS`, async ({
page,
}) => {
const media = observeLiveFormatMedia(page);
await configureLiveFormat(page, player);
await addLiveFormatPortal(page);
await liveChannels(page).nth(channel).click();
await expectLiveFormatPlaying(page, player);
if (!channel)
expect(media).toContainEqual({
file: '10000.m3u8',
status: 200,
});
expect(media).toContainEqual({
file: channel ? '10001.m3u8' : 'denied-segment.ts',
status: 403,
});
expect(
media.filter((r) => r.file === `${10000 + channel}.ts`)
).toEqual([{ file: `${10000 + channel}.ts`, status: 200 }]);
await expect(
page.locator('[data-test-id="playback-diagnostic-banner"]')
).toBeHidden();
});
}
}
test('@web @xtream Auto live format: TS failure is terminal without a cycle', async ({
page,
}) => {
const media = observeLiveFormatMedia(page);
await configureLiveFormat(page, 'html5');
await addLiveFormatPortal(page);
await liveChannels(page).nth(2).click();
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
await expect(banner).toBeVisible({ timeout: 50_000 });
expect(media.filter((r) => r.file === '10002.ts')).toEqual([
{ file: '10002.ts', status: 403 },
]);
await expect(banner).toContainText(/MPEG-TS|mpegts/i);
});
for (const format of ['ts', 'm3u8']) {
test(`@web @xtream Auto live format: manual ${format} wins`, async ({
page,
}) => {
const media = observeLiveFormatMedia(page);
await configureLiveFormat(page, 'html5', format);
await addLiveFormatPortal(page);
await liveChannels(page).first().click();
if (format === 'ts') {
await expectLiveFormatPlaying(page, 'html5');
expect(media.some((r) => r.file.endsWith('.m3u8'))).toBe(false);
} else {
await expect(
page.locator('[data-test-id="playback-diagnostic-banner"]')
).toBeVisible({ timeout: 50_000 });
expect(media.some((r) => r.file === '10000.ts')).toBe(false);
}
});
}
test('@web @xtream Auto live format: channel switch cancels delayed HLS failure', async ({
page,
}) => {
const media = observeLiveFormatMedia(page);
await configureLiveFormat(page, 'html5');
await addLiveFormatPortal(page);
const pending = page.waitForRequest('**/delayed-segment.ts');
await liveChannels(page).nth(3).click();
await pending;
await liveChannels(page).nth(1).click();
await expectLiveFormatPlaying(page, 'html5');
expect(media.some((r) => r.file === '10003.ts')).toBe(false);
expect(media.filter((r) => r.file === '10001.ts')).toHaveLength(1);
});
test('@web @xtream Auto live format: no TS advertisement means no downgrade', async ({
page,
}) => {
await page.route('**/localhost:3000/xtream**', async (route) => {
const url = new URL(route.request().url());
url.host = new URL(liveFormatMock).host;
const response = await route.fetch({ url: url.toString() });
const json = await response.json();
if (json.payload?.user_info)
json.payload.user_info.allowed_output_formats = ['m3u8'];
await route.fulfill({ response, json });
});
const media = observeLiveFormatMedia(page);
await configureLiveFormat(page, 'html5');
await addLiveFormatPortal(page);
await liveChannels(page).first().click();
await expect(
page.locator('[data-test-id="playback-diagnostic-banner"]')
).toBeVisible({ timeout: 50_000 });
expect(media.some((r) => r.file === '10000.ts')).toBe(false);
});
test('@web @xtream Auto live format: leaving the host aborts pending media without TS', async ({
page,
}) => {
const media = observeLiveFormatMedia(page);
await configureLiveFormat(page, 'html5');
await addLiveFormatPortal(page);
const pending = page.waitForRequest('**/delayed-segment.ts');
await liveChannels(page).nth(3).click();
const request = await pending;
const aborted = page.waitForEvent('requestfailed', (r) => r === request);
await page.getByRole('link', { name: 'Open settings' }).click();
await aborted;
expect(media.some((r) => r.file === '10003.ts')).toBe(false);
await expect(page.locator('app-web-player-view')).toHaveCount(0);
});
@@ -0,0 +1,79 @@
import { expect, type Page } from '@playwright/test';
export const liveFormatMock = `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}`;
export type LiveFormatPlayer = 'html5' | 'videojs' | 'artplayer';
export async function configureLiveFormat(
page: Page,
player: LiveFormatPlayer,
format = 'auto'
) {
await page.getByRole('link', { name: 'Open settings' }).click();
await page.locator('[data-test-id="settings-section-playback"]').click();
await page.locator('[data-test-id="select-video-player"]').click();
await page
.getByRole('option', {
name: {
html5: 'HTML5 video player',
videojs: 'Video.js player',
artplayer: 'ArtPlayer',
}[player],
exact: true,
})
.click();
await page.locator('[data-test-id="select-stream-format"]').click();
await page.getByRole('option', { name: format, exact: true }).click();
if (player !== 'videojs' || format !== 'auto') {
const save = page.getByRole('button', { name: 'Save changes' });
await expect(save).toBeVisible();
await save.click();
await expect(save).toBeHidden();
}
await page.getByRole('button', { name: 'Back', exact: true }).click();
}
export async function addLiveFormatPortal(page: Page) {
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
await dialog.locator('#title').fill('Synthetic live');
await dialog.locator('#serverUrl').fill(liveFormatMock);
await dialog.locator('#username').fill('live-fallback');
await dialog.locator('#password').fill('live-fallback');
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await page.waitForURL(/xtreams.*vod/);
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
await page.locator('.context-panel .category-item').first().click();
await expect(liveChannels(page).first()).toBeVisible();
}
export const liveChannels = (page: Page) =>
page.locator('app-live-stream-layout [data-test-id="channel-item"]');
export function observeLiveFormatMedia(page: Page) {
const media: { file: string; status: number }[] = [];
page.on('response', (response) => {
if (response.url().startsWith(`${liveFormatMock}/live/`))
media.push({
file: new URL(response.url()).pathname.split('/').pop() ?? '',
status: response.status(),
});
});
return media;
}
export async function expectLiveFormatPlaying(
page: Page,
player: LiveFormatPlayer
) {
await expect(
page.locator(
`app-${player === 'html5' ? 'html-video' : player === 'videojs' ? 'vjs' : 'art'}-player`
)
).toBeVisible();
const video = page.locator('app-web-player-view video').first();
await expect
.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime), {
timeout: 50_000,
})
.toBeGreaterThan(0.5);
return video;
}