mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
1 parent
ef2418a2c5
commit
436825bdec
37 files changed
+1457
-67
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSources,
|
||||
openSourceEditor,
|
||||
updateSourceDialog,
|
||||
saveSourceDialog,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
addLiveFormatPortal,
|
||||
configureLiveFormat,
|
||||
expectLiveFormatPlaying,
|
||||
liveChannels,
|
||||
observeLiveFormatMedia,
|
||||
} from './xtream-live-format.fixture';
|
||||
|
||||
for (const player of ['html5', 'artplayer'] as const) {
|
||||
test(`@electron @xtream Auto live format ${player}: failing segment to playable TS`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
args: ['--autoplay-policy=no-user-gesture-required'],
|
||||
});
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
const media = observeLiveFormatMedia(page);
|
||||
await configureLiveFormat(page, player);
|
||||
await addLiveFormatPortal(page);
|
||||
await openSources(page);
|
||||
const dialog = await openSourceEditor(page, 'Synthetic live');
|
||||
await updateSourceDialog(dialog, {
|
||||
userAgent: 'IPTVnator-1513-fixture',
|
||||
});
|
||||
await saveSourceDialog(page, dialog);
|
||||
await sourceRowByTitle(page, 'Synthetic live').first().click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Live TV', exact: true })
|
||||
.click();
|
||||
await page.locator('.context-panel .category-item').first().click();
|
||||
const [tsResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().endsWith('/10000.ts'),
|
||||
{ timeout: 60_000 }
|
||||
),
|
||||
liveChannels(page).first().click(),
|
||||
expectLiveFormatPlaying(page, player),
|
||||
]);
|
||||
expect(await tsResponse.headerValue('x-fixture-user-agent')).toBe(
|
||||
'IPTVnator-1513-fixture'
|
||||
);
|
||||
|
||||
expect(media).toContainEqual({ file: '10000.m3u8', status: 200 });
|
||||
expect(media).toContainEqual({
|
||||
file: 'denied-segment.ts',
|
||||
status: 403,
|
||||
});
|
||||
expect(media.filter((r) => r.file === '10000.ts')).toEqual([
|
||||
{ file: '10000.ts', status: 200 },
|
||||
]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('@electron @xtream Video.js supports the manual TS workaround', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
args: ['--autoplay-policy=no-user-gesture-required'],
|
||||
});
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
const media = observeLiveFormatMedia(page);
|
||||
await configureLiveFormat(page, 'videojs', 'ts');
|
||||
await addLiveFormatPortal(page);
|
||||
await liveChannels(page).first().click();
|
||||
await expectLiveFormatPlaying(page, 'videojs');
|
||||
expect(media.some((r) => r.file.endsWith('.m3u8'))).toBe(false);
|
||||
expect(media.filter((r) => r.file === '10000.ts')).toHaveLength(1);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -358,7 +358,7 @@
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Dockt den Serienplayer in breiten Fenstern links an und zeigt daneben die kommenden Folgen an.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Senderliste im Vollbild",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Bewege die Maus im Vollbild an den linken Rand oder drücke C, um die Senderliste einzublenden und den Sender zu wechseln, ohne das Vollbild zu verlassen.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto bevorzugt HLS. Schlägt Live-TV in MPV, VLC oder Embedded MPV fehl, den Player stoppen, ts wählen, speichern und den Sender erneut öffnen. Webplayer können bei einem anfänglichen HLS-HTTP-Fehler einmal angebotenes TS versuchen.",
|
||||
"LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus",
|
||||
"THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus",
|
||||
"COVER_SIZE": "Cover-Größe",
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "On wide windows, dock the series player to the left and show the upcoming episodes beside it.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Channel list in fullscreen",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Rest the mouse on the left edge or press C while in fullscreen to slide in the channel list and switch channels without leaving fullscreen.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto prefers HLS. If live playback fails in MPV, VLC or Embedded MPV, stop the player, select ts, save and reopen the channel. Web players can try advertised TS once after an initial HLS HTTP failure.",
|
||||
"LANGUAGE_DESCRIPTION": "Select language of the application",
|
||||
"THEME_DESCRIPTION": "Select visual theme of the application",
|
||||
"COVER_SIZE": "Cover size",
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "В широких окнах закреплять плеер сериала слева и показывать следующие серии рядом с ним.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Список каналов в полноэкранном режиме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Подведите мышь к левому краю или нажмите C в полноэкранном режиме, чтобы выдвинуть список каналов и переключить канал, не выходя из полноэкранного режима.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto предпочитает HLS. При сбое в MPV, VLC или Embedded MPV остановите плеер, выберите ts, сохраните настройки и снова откройте канал. Веб-плееры могут один раз попробовать заявленный TS после первоначальной HTTP-ошибки HLS.",
|
||||
"LANGUAGE_DESCRIPTION": "Выбрать язык приложения",
|
||||
"THEME_DESCRIPTION": "Выбрать визуальную тему оформления",
|
||||
"COVER_SIZE": "Размер обложки",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Express } from 'express';
|
||||
|
||||
/** Entirely local synthetic media. IDs exercise initial manifest and segment failures. */
|
||||
export function installLiveFormatFixture(app: Express): void {
|
||||
app.get('/live/live-fallback/live-fallback/:file', (request, response) => {
|
||||
const file = String(request.params['file']);
|
||||
response.set('Cache-Control', 'no-store');
|
||||
response.set('X-Fixture-User-Agent', request.get('user-agent') ?? '');
|
||||
if (file.endsWith('.m3u8')) {
|
||||
if (file === '10001.m3u8') {
|
||||
response.status(403).end();
|
||||
return;
|
||||
}
|
||||
response
|
||||
.type('application/vnd.apple.mpegurl')
|
||||
.send(
|
||||
[
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXTINF:6,',
|
||||
file === '10003.m3u8'
|
||||
? 'delayed-segment.ts'
|
||||
: 'denied-segment.ts',
|
||||
'#EXT-X-ENDLIST',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (file === 'delayed-segment.ts') {
|
||||
const timer = setTimeout(() => response.status(403).end(), 1500);
|
||||
response.on('close', () => clearTimeout(timer));
|
||||
return;
|
||||
}
|
||||
if (file === 'denied-segment.ts' || file === '10002.ts') {
|
||||
response.status(403).end();
|
||||
return;
|
||||
}
|
||||
response
|
||||
.type('video/mp2t')
|
||||
.send(
|
||||
readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'apps/xtream-mock-server/src/fixtures/live.mpegts'
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -31,6 +31,17 @@ export interface ScenarioConfig {
|
||||
* Unknown credential pairs use a hash of "username:password" as seed.
|
||||
*/
|
||||
export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
'live-fallback:live-fallback': {
|
||||
name: 'live-format-fallback',
|
||||
description: 'Local HLS failures and playable TS',
|
||||
seed: 1513,
|
||||
categoryCount: { live: 1, vod: 1, series: 1 },
|
||||
itemsPerCategory: 4,
|
||||
seasonsPerSeries: 1,
|
||||
episodesPerSeason: 1,
|
||||
accountStatus: 'Active',
|
||||
expiryDate: '2099-12-31',
|
||||
},
|
||||
'user1:pass1': {
|
||||
name: 'default',
|
||||
description: 'Balanced portal — 8 categories, 40 items each',
|
||||
|
||||
@@ -51,6 +51,31 @@ describe('Xtream mock server factory', () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('serves the synthetic manifest-success/segment-failure and TS alternatives locally', async () => {
|
||||
const running = await startLoopbackServer(
|
||||
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
|
||||
);
|
||||
const base = `${running.origin}/live/live-fallback/live-fallback`;
|
||||
try {
|
||||
const manifest = await fetch(`${base}/10000.m3u8`);
|
||||
expect(manifest.status).toBe(200);
|
||||
expect(await manifest.text()).toContain('denied-segment.ts');
|
||||
expect((await fetch(`${base}/denied-segment.ts`)).status).toBe(403);
|
||||
expect((await fetch(`${base}/10001.m3u8`)).status).toBe(403);
|
||||
expect((await fetch(`${base}/10002.ts`)).status).toBe(403);
|
||||
const ts = await fetch(`${base}/10000.ts`, {
|
||||
headers: { 'User-Agent': 'synthetic' },
|
||||
});
|
||||
expect(ts.headers.get('x-fixture-user-agent')).toBe('synthetic');
|
||||
const bytes = new Uint8Array(await ts.arrayBuffer());
|
||||
expect(bytes.length).toBeGreaterThan(188);
|
||||
expect(bytes[0]).toBe(0x47);
|
||||
expect(bytes[188]).toBe(0x47);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps performance controls absent unless explicitly enabled', async () => {
|
||||
const running = await startLoopbackServer(
|
||||
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
|
||||
|
||||
@@ -6,6 +6,7 @@ import express, {
|
||||
type Request,
|
||||
type Response,
|
||||
} from 'express';
|
||||
import { installLiveFormatFixture } from './live-format-fixture.js';
|
||||
import { resetAll } from './data-store.js';
|
||||
import { renderMarketingAssetSvg } from './generators/marketing.generator.js';
|
||||
import { installPerformanceControlRoutes } from './performance-control-routes.js';
|
||||
@@ -107,6 +108,7 @@ export function createXtreamMockApp(
|
||||
dispatchAction(request, response)
|
||||
);
|
||||
});
|
||||
if (!controlEnabled) installLiveFormatFixture(app);
|
||||
installStreamRoutes(app, controlEnabled);
|
||||
app.get('/xtream', (request, response, next) => {
|
||||
dispatchWithControl(controller, request, response, next, 'proxy', () =>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Synthetic playback fixtures
|
||||
|
||||
`live.mpegts` is six seconds of generated color-pattern video and a sine wave,
|
||||
H.264 baseline + AAC, used by the local Xtream live-format regression account.
|
||||
No real provider media or credentials are included.
|
||||
|
||||
Regenerate from the repository root with FFmpeg:
|
||||
|
||||
```sh
|
||||
ffmpeg -f lavfi -i testsrc2=size=160x90:rate=25 -f lavfi -i sine=frequency=440:sample_rate=48000 -t 6 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -g 25 -c:a aac -b:a 48k -f mpegts apps/xtream-mock-server/src/fixtures/live.mpegts
|
||||
```
|
||||
|
||||
The mock returns the fixture only for synthetic live-format account media;
|
||||
manifest and denied-segment routes return controlled 200/403 responses. Web and
|
||||
Electron E2E tests check advancing playback, not HTTP status alone.
|
||||
Binary file not shown.
Reference in new issue
Block a user