mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations * docs(playback): plan recovery recommendations * refactor(playback): extract diagnostic utilities * feat(playback): define recovery recommendation contracts * feat(playback): rank recovery recommendations * feat(playback): track session recovery attempts * feat(playback): identify content recovery sessions * feat(ui): add ranked playback diagnostic panel * feat(playback): switch temporarily to recommended players * test(playback): cover temporary player recommendation * test(playback): verify recommendation capability guards * docs(playback): document recovery recommendations * fix(playback): keep recovery keys credential-free * fix(playback): remove derived tracking ownership * fix(playback): preserve distinct recovery fallbacks * fix(playback): reset resume for new sources * fix(playback): preserve desktop recovery guidance * docs(playback): clarify recovery policy exceptions * fix(playback): reject stale progress updates * fix(playback): keep protected recovery guidance neutral * test(playback): cover stale progress output * fix(playback): neutralize protected diagnostic copy * fix(playback): harden runtime guidance ownership * fix(playback): stabilize recovery application ownership * fix(ci): classify playback util coverage * fix(e2e): preserve playback fixture bytes
This commit is contained in:
1 parent
5e4f2ca3dd
commit
fd96b85c19
176 files changed
+15228
-1908
No files matched your search
@@ -2,6 +2,9 @@ import playwright from 'eslint-plugin-playwright';
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['**/fixtures/playback/corrupt.ts'],
|
||||
},
|
||||
playwright.configs['flat/recommended'],
|
||||
...baseConfig,
|
||||
{
|
||||
|
||||
@@ -150,4 +150,13 @@ test('@web @m3u @dash unsupported DRM shows the encryption diagnostic', async ({
|
||||
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
await expect(banner).toContainText(/encrypted or DRM-protected/i);
|
||||
await expect(
|
||||
banner.locator('[data-test-id="playback-fallback-mpv"]')
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
banner.locator('[data-test-id="playback-fallback-vlc"]')
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
banner.locator('[data-test-id^="playback-recommendation-"]')
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
This is intentionally not an MPEG transport stream segment.
|
||||
@@ -0,0 +1,7 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:1,
|
||||
corrupt.ts
|
||||
#EXT-X-ENDLIST
|
||||
@@ -0,0 +1 @@
|
||||
This fixture intentionally declares Matroska without playable media bytes.
|
||||
@@ -0,0 +1,141 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
const FIXTURE_DIR = join(__dirname, 'fixtures/playback');
|
||||
const FIXTURE_HOST = 'https://playback-fixture.local';
|
||||
const FATAL_HLS_PLAYLIST = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:-1 tvg-id="fatal-hls" group-title="Playback",Fatal HLS',
|
||||
`${FIXTURE_HOST}/fatal-media.m3u8`,
|
||||
].join('\n');
|
||||
|
||||
const PLAYBACK_FIXTURES = new Map([
|
||||
[
|
||||
'/fatal-media.m3u8',
|
||||
{
|
||||
body: readFileSync(join(FIXTURE_DIR, 'fatal-media.m3u8')),
|
||||
contentType: 'application/vnd.apple.mpegurl',
|
||||
},
|
||||
],
|
||||
[
|
||||
'/corrupt.ts',
|
||||
{
|
||||
body: readFileSync(join(FIXTURE_DIR, 'corrupt.ts')),
|
||||
contentType: 'video/mp2t',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
test.use({
|
||||
launchOptions: {
|
||||
args: ['--autoplay-policy=no-user-gesture-required'],
|
||||
},
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'HLS recovery coverage targets Chromium'
|
||||
);
|
||||
|
||||
async function servePlaybackFixtures(page: Page): Promise<void> {
|
||||
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
|
||||
const fixture = PLAYBACK_FIXTURES.get(
|
||||
new URL(route.request().url()).pathname
|
||||
);
|
||||
if (!fixture) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'text/plain',
|
||||
body: 'not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: fixture.contentType,
|
||||
body: fixture.body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openSettings(page: Page): Promise<void> {
|
||||
await page.getByRole('link', { name: 'Open settings' }).click();
|
||||
await page.waitForURL(/\/workspace\/settings$/);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Back', exact: true })
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectAndSaveHtml5(page: Page): Promise<void> {
|
||||
const playerSelect = page.locator('[data-test-id="select-video-player"]');
|
||||
await playerSelect.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'HTML5 video player', exact: true })
|
||||
.click();
|
||||
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
}
|
||||
|
||||
async function importFatalHlsPlaylist(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
|
||||
await dialog
|
||||
.getByLabel('Insert m3u(8) playlist as text')
|
||||
.fill(FATAL_HLS_PLAYLIST);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||
]);
|
||||
await expect(page.getByText('1 channel')).toBeVisible();
|
||||
}
|
||||
|
||||
test('@web @m3u @playback temporarily switches to the recommended player', async ({
|
||||
page,
|
||||
}) => {
|
||||
await servePlaybackFixtures(page);
|
||||
await page.goto('/');
|
||||
|
||||
await openSettings(page);
|
||||
await selectAndSaveHtml5(page);
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
await page.waitForURL(/\/workspace\/dashboard$/);
|
||||
|
||||
await importFatalHlsPlaylist(page);
|
||||
await page.getByText('1. Fatal HLS', { exact: true }).click();
|
||||
|
||||
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
|
||||
const videoJsRecommendation = page.locator(
|
||||
'[data-test-id="playback-recommendation-videojs"]'
|
||||
);
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
banner.locator('[data-test-id="playback-diagnostic-details"]')
|
||||
).toContainText(
|
||||
/HLS\.js[\s\S]*type=mediaError[\s\S]*details=fragParsingError[\s\S]*disposition=fatal/
|
||||
);
|
||||
await expect(videoJsRecommendation).toBeVisible();
|
||||
await expect(videoJsRecommendation).toHaveClass(
|
||||
/web-player-diagnostic__player-card--primary/
|
||||
);
|
||||
|
||||
await videoJsRecommendation.click();
|
||||
await expect(page.locator('app-vjs-player')).toBeVisible();
|
||||
|
||||
await openSettings(page);
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/workspace\/settings$/);
|
||||
const persistedPlayerSelect = page.locator(
|
||||
'[data-test-id="select-video-player"]'
|
||||
);
|
||||
await expect(persistedPlayerSelect).toBeVisible();
|
||||
await expect(persistedPlayerSelect).toBeEnabled();
|
||||
await expect(persistedPlayerSelect).toHaveText('HTML5 video player');
|
||||
});
|
||||
@@ -17,5 +17,6 @@
|
||||
"playwright.config.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.e2e.ts"
|
||||
]
|
||||
],
|
||||
"exclude": ["src/fixtures/playback/corrupt.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user