mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
* fix(i18n): translate hard-coded labels, snackbars and missing keys Catalog screens, snackbars and external player messages showed English in every locale, and seven keys used in code were missing from en.json, so ngx-translate rendered them raw. - Catalog: "All items", item and channel counts, channel sort menus and tooltips, unnamed-category and empty-category labels, LIVE/PAUSED badges and the Xtream global search summary are translated. The Xtream and Stalker stores no longer bake an English name into the every-item sentinel; the facades return a null title and the view translates it. - Snackbars: Xtream/Stalker request failures, the 413 upload error, backup export/import results and the category visibility failure use keys; every "Close" action uses CLOSE. - External player: the main process sends an error code instead of an English sentence (player-error event, session errorCode, and a tag in rejected launch errors); the renderer, dock and VOD primary button translate it. The external player info dialog is translated. - Adds the seven missing keys and 43 new ones, translated in all 18 locales; seven legitimately identical values are baselined. - tools/i18n/check-usage.mjs fails on keys used in code but missing from en.json; it runs in i18n:check (and so in CI through i18n:validate). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(i18n): translate catalog counts in the template and skip inline template comments - The category subtitle hands a key to the translate pipe instead of caching translate.instant(), so a cold start re-renders it once the language file loads. - The Xtream live root count uses the singular/plural item keys, so one result no longer reads "1 channels". - check-usage.mjs strips HTML comments inside inline templates of TypeScript files, so a commented-out key no longer fails the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(i18n): check keys in parenthesised translate pipe operands `(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate` yields keys that neither precede the pipe directly nor contain a dot, so the usage check missed them. It now reads the ternary and fallback branches of a parenthesised operand; a literal compared in the condition is not read as a key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): expect the translated external player failure in the dock A launch failure without an error code now shows the translated generic status in the playback dock, with the raw main-process detail as its tooltip. The ClearKey DASH flow asserted the raw English text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): keep the IPC error tag out of the stored session detail A tagged launch failure reached ExternalPlayerSession.error unchanged, so the dock tooltip showed "[iptvnator:external-player:start-failed]". The registry now strips the tag when it stores the detail; the rejected IPC error keeps it for the renderer to read the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(web): expect translated portal request failure toasts #1861's new resolved-failure specs asserted the English toast text; the toasts now go through PORTALS.REQUEST_ERRORS keys, so the specs check the key and its message/status params. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(i18n): read only returned branches of a grouped translate operand A literal at the start of a condition, as in `('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate`, was taken for a translation key, so a valid template could fail the usage check. A grouped literal now counts only when it ends its operand (end of group, `:`, `||` or `??`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
963 lines
35 KiB
TypeScript
963 lines
35 KiB
TypeScript
import { createServer, Server } from 'http';
|
||
import { readFileSync } from 'fs';
|
||
import { basename, join } from 'path';
|
||
import type {
|
||
ExternalPlayerName,
|
||
ExternalPlayerSession,
|
||
PlayerContentInfo,
|
||
} from '@iptvnator/shared/interfaces';
|
||
import {
|
||
channelItemByTitle,
|
||
closeElectronApp,
|
||
expect,
|
||
goToDashboard,
|
||
launchElectronApp,
|
||
LaunchedElectronApp,
|
||
openAddPlaylistDialog,
|
||
openSourceEditor,
|
||
openSettings,
|
||
openPlaylistRecent,
|
||
openPlaylistFavorites,
|
||
openGlobalRecent,
|
||
openGlobalFavorites,
|
||
openSettingsSection,
|
||
openSources,
|
||
saveSourceDialog,
|
||
saveSettings,
|
||
sourceRowByTitle,
|
||
switchUnifiedCollectionScope,
|
||
test,
|
||
updateSourceDialog,
|
||
waitForM3uCatalog,
|
||
workspaceRoot,
|
||
} from './electron-test-fixtures';
|
||
import { startAndConfirmPlayback } from './playable-stream-fixture';
|
||
|
||
/**
|
||
* DASH + ClearKey playback in the real Electron runtime — the only automated
|
||
* proof that ClearKey EME works in the packaged `file://` renderer (secure
|
||
* context). Uses the shared offline fixtures from apps/web-e2e/src/fixtures.
|
||
*/
|
||
|
||
const DASH_FIXTURE_DIR = join(workspaceRoot, 'apps/web-e2e/src/fixtures/dash');
|
||
const PLAYBACK_FIXTURE_DIR = join(
|
||
workspaceRoot,
|
||
'apps/web-e2e/src/fixtures/playback'
|
||
);
|
||
|
||
type PlaybackFixture = {
|
||
readonly contentType: string;
|
||
readonly path: string;
|
||
};
|
||
|
||
const FIXTURE_FILES: ReadonlyMap<string, PlaybackFixture> = new Map([
|
||
[
|
||
'clearkey.mpd',
|
||
{
|
||
contentType: 'application/dash+xml',
|
||
path: join(DASH_FIXTURE_DIR, 'clearkey.mpd'),
|
||
},
|
||
],
|
||
[
|
||
'clearkey-video.mp4',
|
||
{
|
||
contentType: 'video/mp4',
|
||
path: join(DASH_FIXTURE_DIR, 'clearkey-video.mp4'),
|
||
},
|
||
],
|
||
[
|
||
'clearkey-audio.mp4',
|
||
{
|
||
contentType: 'video/mp4',
|
||
path: join(DASH_FIXTURE_DIR, 'clearkey-audio.mp4'),
|
||
},
|
||
],
|
||
[
|
||
'unsupported.mkv',
|
||
{
|
||
contentType: 'video/matroska',
|
||
path: join(PLAYBACK_FIXTURE_DIR, 'unsupported.mkv'),
|
||
},
|
||
],
|
||
]);
|
||
|
||
const CLEARKEY_KID = '00112233445566778899aabbccddeeff';
|
||
const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100';
|
||
|
||
type DashFixtureServer = {
|
||
close: () => Promise<void>;
|
||
origin: string;
|
||
};
|
||
|
||
/** Serves the explicit offline fixture allowlist with HTTP Range support
|
||
* (Shaka fetches init segments and the sidx via byte ranges). */
|
||
async function startDashFixtureServer(): Promise<DashFixtureServer> {
|
||
const server: Server = createServer((request, response) => {
|
||
const pathname = (request.url ?? '').split('?')[0];
|
||
const fileName = basename(pathname);
|
||
const fixture = FIXTURE_FILES.get(fileName);
|
||
if (!fixture) {
|
||
response.writeHead(404);
|
||
response.end('not found');
|
||
return;
|
||
}
|
||
let body: Buffer;
|
||
try {
|
||
body = readFileSync(fixture.path);
|
||
} catch {
|
||
response.writeHead(404);
|
||
response.end('not found');
|
||
return;
|
||
}
|
||
|
||
const range = /bytes=(\d+)-(\d+)?/.exec(request.headers.range ?? '');
|
||
if (!range) {
|
||
response.writeHead(200, {
|
||
'Content-Type': fixture.contentType,
|
||
'Accept-Ranges': 'bytes',
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Content-Length': body.length,
|
||
});
|
||
response.end(body);
|
||
return;
|
||
}
|
||
|
||
const start = Number(range[1]);
|
||
const end = range[2] ? Number(range[2]) : body.length - 1;
|
||
const chunk = body.subarray(start, end + 1);
|
||
response.writeHead(206, {
|
||
'Content-Type': fixture.contentType,
|
||
'Accept-Ranges': 'bytes',
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Content-Range': `bytes ${start}-${end}/${body.length}`,
|
||
'Content-Length': chunk.length,
|
||
});
|
||
response.end(chunk);
|
||
});
|
||
|
||
await new Promise<void>((resolvePromise, reject) => {
|
||
server.once('error', reject);
|
||
server.listen(0, '127.0.0.1', () => {
|
||
server.off('error', reject);
|
||
resolvePromise();
|
||
});
|
||
});
|
||
const address = server.address();
|
||
if (!address || typeof address === 'string') {
|
||
throw new Error('Failed to resolve dash fixture server address.');
|
||
}
|
||
|
||
return {
|
||
origin: `http://127.0.0.1:${address.port}`,
|
||
close: () =>
|
||
new Promise<void>((resolvePromise, reject) => {
|
||
server.close((error) =>
|
||
error ? reject(error) : resolvePromise()
|
||
);
|
||
}),
|
||
};
|
||
}
|
||
|
||
function buildDashPlaylist(origin: string): string {
|
||
// Regression for #1466: ordinary Base64 includes + and / in this key.
|
||
const license = JSON.stringify({
|
||
keys: [
|
||
{
|
||
kty: 'oct',
|
||
kid: Buffer.from(CLEARKEY_KID, 'hex').toString('base64'),
|
||
k: Buffer.from(CLEARKEY_KEY, 'hex').toString('base64'),
|
||
},
|
||
],
|
||
type: 'temporary',
|
||
});
|
||
return [
|
||
'#EXTM3U',
|
||
'#EXTINF:-1 tvg-id="ck-dash" group-title="DASH",ClearKey DASH',
|
||
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
|
||
`#KODIPROP:inputstream.adaptive.license_key=${license}`,
|
||
`${origin}/clearkey.mpd`,
|
||
'#EXTINF:-1 tvg-id="wv-dash" group-title="DASH",Widevine DASH',
|
||
'#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha',
|
||
'#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv',
|
||
`${origin}/clearkey.mpd?widevine=1`,
|
||
'#EXTINF:-1 tvg-id="unsupported-mkv" group-title="DASH",Unsupported MKV',
|
||
`${origin}/unsupported.mkv`,
|
||
].join('\n');
|
||
}
|
||
|
||
async function importDashPlaylistFromText(
|
||
app: LaunchedElectronApp,
|
||
playlist: string
|
||
): Promise<void> {
|
||
await openAddPlaylistDialog(app.mainWindow);
|
||
const dialog = app.mainWindow.locator('mat-dialog-container').last();
|
||
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
|
||
await dialog.locator('textarea').fill(playlist);
|
||
await dialog
|
||
.getByRole('button', { name: 'Add playlist', exact: true })
|
||
.click();
|
||
await dialog.waitFor({ state: 'detached' });
|
||
await waitForM3uCatalog(app.mainWindow);
|
||
}
|
||
|
||
type ExternalPlayerIpcArguments = [
|
||
url: string,
|
||
title: string,
|
||
thumbnail: string | undefined,
|
||
userAgent: string | undefined,
|
||
referer: string | undefined,
|
||
origin: string | undefined,
|
||
contentInfo: PlayerContentInfo | undefined,
|
||
startTime: number | undefined,
|
||
headers: Record<string, string> | undefined,
|
||
];
|
||
|
||
type CapturedExternalPlayerLaunch = {
|
||
args: ExternalPlayerIpcArguments;
|
||
player: ExternalPlayerName;
|
||
};
|
||
|
||
type PlaybackRecommendationCaptureSnapshot = {
|
||
closed: string[];
|
||
completed: number;
|
||
launches: CapturedExternalPlayerLaunch[];
|
||
released: boolean;
|
||
};
|
||
|
||
type PlaybackRecommendationCaptureState =
|
||
PlaybackRecommendationCaptureSnapshot & {
|
||
releaseFirstResponse: (() => void) | undefined;
|
||
sessions: Record<string, ExternalPlayerSession>;
|
||
waitForFirstResponse: Promise<void>;
|
||
};
|
||
|
||
async function installPlaybackRecommendationLaunchCapture(
|
||
app: LaunchedElectronApp
|
||
): Promise<void> {
|
||
await app.electronApp.evaluate(({ ipcMain }) => {
|
||
let releaseFirstResponse: (() => void) | undefined;
|
||
const waitForFirstResponse = new Promise<void>((resolve) => {
|
||
releaseFirstResponse = resolve;
|
||
});
|
||
const state: PlaybackRecommendationCaptureState = {
|
||
closed: [],
|
||
completed: 0,
|
||
launches: [],
|
||
released: false,
|
||
releaseFirstResponse,
|
||
sessions: {},
|
||
waitForFirstResponse,
|
||
};
|
||
const globalRef = globalThis as typeof globalThis & {
|
||
__playbackRecommendationCapture?: PlaybackRecommendationCaptureState;
|
||
};
|
||
globalRef.__playbackRecommendationCapture = state;
|
||
|
||
const captureLaunch =
|
||
(player: ExternalPlayerName) =>
|
||
async (
|
||
rawEvent: unknown,
|
||
url: string,
|
||
title: string,
|
||
thumbnail?: string,
|
||
userAgent?: string,
|
||
referer?: string,
|
||
origin?: string,
|
||
contentInfo?: PlayerContentInfo,
|
||
startTime?: number,
|
||
headers?: Record<string, string>
|
||
) => {
|
||
const invocation = state.launches.push({
|
||
args: [
|
||
url,
|
||
title,
|
||
thumbnail,
|
||
userAgent,
|
||
referer,
|
||
origin,
|
||
contentInfo,
|
||
startTime,
|
||
headers,
|
||
],
|
||
player,
|
||
});
|
||
const now = new Date().toISOString();
|
||
const id = `e2e-recommended-${player}-${invocation}`;
|
||
const event = rawEvent as {
|
||
sender: {
|
||
send: (
|
||
channel: string,
|
||
session: ExternalPlayerSession
|
||
) => void;
|
||
};
|
||
};
|
||
const launching: ExternalPlayerSession = {
|
||
canClose: true,
|
||
id,
|
||
player,
|
||
startedAt: now,
|
||
status: 'launching',
|
||
streamUrl: url,
|
||
thumbnail: thumbnail ?? null,
|
||
title,
|
||
updatedAt: now,
|
||
};
|
||
state.sessions[id] = launching;
|
||
event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', launching);
|
||
|
||
if (invocation === 1 && !state.released) {
|
||
await state.waitForFirstResponse;
|
||
}
|
||
|
||
state.completed += 1;
|
||
const completed: ExternalPlayerSession = {
|
||
...launching,
|
||
canClose: player === 'mpv',
|
||
error:
|
||
player === 'vlc'
|
||
? 'E2E player launch failed'
|
||
: undefined,
|
||
player,
|
||
status: player === 'mpv' ? 'opened' : 'error',
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
state.sessions[id] = completed;
|
||
event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', completed);
|
||
return completed;
|
||
};
|
||
|
||
ipcMain.removeHandler('OPEN_MPV_PLAYER');
|
||
ipcMain.removeHandler('OPEN_VLC_PLAYER');
|
||
ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv'));
|
||
ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc'));
|
||
ipcMain.removeHandler('CLOSE_EXTERNAL_PLAYER_SESSION');
|
||
ipcMain.handle(
|
||
'CLOSE_EXTERNAL_PLAYER_SESSION',
|
||
(rawEvent: unknown, sessionId: string) => {
|
||
const session = state.sessions[sessionId];
|
||
if (!session) return null;
|
||
const closed: ExternalPlayerSession = {
|
||
...session,
|
||
canClose: false,
|
||
status: 'closed',
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
state.closed.push(sessionId);
|
||
state.sessions[sessionId] = closed;
|
||
const event = rawEvent as {
|
||
sender: {
|
||
send: (
|
||
channel: string,
|
||
update: ExternalPlayerSession
|
||
) => void;
|
||
};
|
||
};
|
||
event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', closed);
|
||
return closed;
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
async function getPlaybackRecommendationCapture(
|
||
app: LaunchedElectronApp
|
||
): Promise<PlaybackRecommendationCaptureSnapshot> {
|
||
return app.electronApp.evaluate(() => {
|
||
const globalRef = globalThis as typeof globalThis & {
|
||
__playbackRecommendationCapture?: PlaybackRecommendationCaptureState;
|
||
};
|
||
const capture = globalRef.__playbackRecommendationCapture;
|
||
if (!capture) {
|
||
throw new Error(
|
||
'Playback recommendation capture is not installed.'
|
||
);
|
||
}
|
||
return {
|
||
closed: capture.closed,
|
||
completed: capture.completed,
|
||
launches: capture.launches,
|
||
released: capture.released,
|
||
};
|
||
});
|
||
}
|
||
|
||
async function releasePlaybackRecommendationCapture(
|
||
app: LaunchedElectronApp
|
||
): Promise<void> {
|
||
await app.electronApp.evaluate(() => {
|
||
const globalRef = globalThis as typeof globalThis & {
|
||
__playbackRecommendationCapture?: PlaybackRecommendationCaptureState;
|
||
};
|
||
const capture = globalRef.__playbackRecommendationCapture;
|
||
if (!capture || capture.released) {
|
||
return;
|
||
}
|
||
capture.released = true;
|
||
capture.releaseFirstResponse?.();
|
||
capture.releaseFirstResponse = undefined;
|
||
});
|
||
}
|
||
|
||
for (const player of ['mpv', 'vlc']) {
|
||
test(`@electron @dash fullscreen panel keeps DASH inline with ${player} configured`, async ({
|
||
dataDir,
|
||
}) => {
|
||
const fixtureServer = await startDashFixtureServer();
|
||
const app = await launchElectronApp(dataDir);
|
||
const page = app.mainWindow;
|
||
try {
|
||
await openSettings(page);
|
||
await openSettingsSection(page, 'playback');
|
||
await page.getByTestId('select-video-player').click();
|
||
await page.getByTestId(player).click();
|
||
await saveSettings(page);
|
||
await goToDashboard(page);
|
||
const playlist = [
|
||
buildDashPlaylist(fixtureServer.origin),
|
||
'#EXTINF:-1 group-title="DASH",Next ClearKey DASH',
|
||
'#KODIPROP:inputstream.adaptive.license_type=clearkey',
|
||
`#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_KID}:${CLEARKEY_KEY}`,
|
||
`${fixtureServer.origin}/clearkey.mpd?next=1`,
|
||
].join('\n');
|
||
await importDashPlaylistFromText(app, playlist);
|
||
await channelItemByTitle(page, 'ClearKey DASH').first().click();
|
||
const video = page.locator('app-web-player-view video').first();
|
||
await expect(video).toBeVisible();
|
||
await expect
|
||
.poll(() =>
|
||
video.evaluate((el: HTMLVideoElement) => el.currentTime)
|
||
)
|
||
.toBeGreaterThan(0.5);
|
||
await page
|
||
.getByRole('button', { name: 'Enter fullscreen' })
|
||
.click();
|
||
const isFullscreen = () =>
|
||
page.evaluate(() => document.fullscreenElement !== null);
|
||
await expect.poll(isFullscreen).toBe(true);
|
||
// Model a delayed first fullscreen paint: the hover-open handoff
|
||
// must survive longer than the panel's mouse-leave grace period.
|
||
await page.addStyleTag({
|
||
content: `
|
||
.fullscreen-channel-panel--open { transition-delay: 600ms, 0s; }
|
||
`,
|
||
});
|
||
await page.getByTestId('fullscreen-channel-panel-hot-zone').hover();
|
||
const panel = page.getByTestId('fullscreen-channel-panel');
|
||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||
await expect(panel).toHaveCSS(
|
||
'transform',
|
||
'matrix(1, 0, 0, 1, 0, 0)'
|
||
);
|
||
await expect(
|
||
panel.getByText('Unsupported MKV', { exact: false })
|
||
).toHaveCount(0);
|
||
const nextChannel = panel
|
||
.getByTestId('channel-item')
|
||
.filter({ hasText: 'Next ClearKey DASH' });
|
||
await nextChannel.click();
|
||
await expect(nextChannel).toHaveClass(/\bactive\b/);
|
||
await expect.poll(isFullscreen).toBe(true);
|
||
await expect
|
||
.poll(() =>
|
||
video.evaluate((el: HTMLVideoElement) => el.currentTime)
|
||
)
|
||
.toBeGreaterThan(0.5);
|
||
expect(await isFullscreen()).toBe(true);
|
||
|
||
// A short context menu has no overflow, but still owns paging
|
||
// keys. Observe the event after the app's document listener.
|
||
const viewportHeight = await page.evaluate(
|
||
() => window.innerHeight
|
||
);
|
||
await page.mouse.move(1, viewportHeight / 2);
|
||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||
await expect(panel).toHaveCSS(
|
||
'transform',
|
||
'matrix(1, 0, 0, 1, 0, 0)'
|
||
);
|
||
await nextChannel.click({ button: 'right' });
|
||
const menu = page.getByRole('menu');
|
||
await expect(menu).toBeVisible();
|
||
const pagingConsumed = page.evaluate(
|
||
() =>
|
||
new Promise<boolean>((resolve) => {
|
||
document.addEventListener(
|
||
'keydown',
|
||
(event) => resolve(event.defaultPrevented),
|
||
{ once: true }
|
||
);
|
||
})
|
||
);
|
||
await menu.getByRole('menuitem').first().press('PageDown');
|
||
expect(await pagingConsumed).toBe(false);
|
||
await expect(nextChannel).toHaveClass(/\bactive\b/);
|
||
|
||
await page.keyboard.press('Escape');
|
||
await expect(menu).toBeHidden();
|
||
await page.mouse.move(600, viewportHeight / 2);
|
||
await expect(panel).toHaveAttribute('aria-hidden', 'true');
|
||
// Original channel 3 is the excluded MKV. Numeric selection
|
||
// must preserve fullscreen without renumbering the catalog.
|
||
await page.keyboard.press('3');
|
||
await expect(page.locator('.channel-number-overlay')).toHaveCount(
|
||
1
|
||
);
|
||
await expect(page.locator('.channel-number-overlay')).toHaveCount(
|
||
0
|
||
);
|
||
expect(await isFullscreen()).toBe(true);
|
||
await expect(nextChannel).toHaveClass(/\bactive\b/);
|
||
|
||
await page.keyboard.press('PageDown');
|
||
const firstChannel = panel.getByTestId('channel-item').filter({
|
||
hasText: 'ClearKey DASH',
|
||
hasNotText: 'Next ClearKey DASH',
|
||
});
|
||
await expect(firstChannel).toHaveClass(/\bactive\b/);
|
||
expect(await isFullscreen()).toBe(true);
|
||
} finally {
|
||
await closeElectronApp(app);
|
||
await fixtureServer.close();
|
||
}
|
||
});
|
||
}
|
||
|
||
for (const configuredPlayer of ['videojs', 'mpv', 'artplayer']) {
|
||
test(`@electron @dash ClearKey reopens from recent and favorites collections with ${configuredPlayer}`, async ({
|
||
dataDir,
|
||
}) => {
|
||
const fixtureServer = await startDashFixtureServer();
|
||
const app = await launchElectronApp(dataDir);
|
||
const page = app.mainWindow;
|
||
try {
|
||
// Video.js is the default; selecting it again leaves no dirty form to save.
|
||
if (configuredPlayer !== 'videojs') {
|
||
await openSettings(page);
|
||
await openSettingsSection(page, 'playback');
|
||
await page.getByTestId('select-video-player').click();
|
||
await page.getByTestId(configuredPlayer).click();
|
||
await saveSettings(page);
|
||
await goToDashboard(page);
|
||
}
|
||
await importDashPlaylistFromText(
|
||
app,
|
||
buildDashPlaylist(fixtureServer.origin)
|
||
);
|
||
const channel = channelItemByTitle(page, 'ClearKey DASH').first();
|
||
// Recent history records the channel once it has really played.
|
||
await startAndConfirmPlayback(page, () => channel.click());
|
||
await channel.locator('.favorite-button').click();
|
||
await expect(
|
||
channel.locator('.favorite-button mat-icon')
|
||
).toHaveText('star');
|
||
|
||
for (const openCollection of [
|
||
openPlaylistRecent,
|
||
openPlaylistFavorites,
|
||
openGlobalRecent,
|
||
openGlobalFavorites,
|
||
]) {
|
||
await openCollection(page);
|
||
if (
|
||
openCollection === openGlobalRecent ||
|
||
openCollection === openGlobalFavorites
|
||
) {
|
||
await switchUnifiedCollectionScope(page, 'All playlists');
|
||
}
|
||
const collection = page.locator('app-unified-live-tab');
|
||
await channelItemByTitle(page, 'ClearKey DASH').first().click();
|
||
const video = collection
|
||
.locator('app-web-player-view video')
|
||
.first();
|
||
await expect(video).toBeVisible();
|
||
await expect
|
||
.poll(
|
||
() =>
|
||
video.evaluate(
|
||
(element: HTMLVideoElement) =>
|
||
element.currentTime
|
||
),
|
||
{ timeout: 20_000 }
|
||
)
|
||
.toBeGreaterThan(0.5);
|
||
await expect(
|
||
page.getByTestId('playback-diagnostic-banner')
|
||
).toBeHidden();
|
||
}
|
||
} finally {
|
||
await closeElectronApp(app);
|
||
await fixtureServer.close();
|
||
}
|
||
});
|
||
}
|
||
|
||
test('@electron @dash ClearKey DASH filters DRM fallback and reports external launch states', async ({
|
||
dataDir,
|
||
}) => {
|
||
const fixtureServer = await startDashFixtureServer();
|
||
const app = await launchElectronApp(dataDir);
|
||
|
||
try {
|
||
const unsupportedFixtureResponse = await app.mainWindow.evaluate(
|
||
async (url) => {
|
||
const response = await fetch(url);
|
||
const body = await response.text();
|
||
return {
|
||
body,
|
||
bodyBytes: new TextEncoder().encode(body).byteLength,
|
||
contentLength: response.headers.get('content-length'),
|
||
contentType: response.headers.get('content-type'),
|
||
rendererProtocol: window.location.protocol,
|
||
status: response.status,
|
||
};
|
||
},
|
||
`${fixtureServer.origin}/unsupported.mkv`
|
||
);
|
||
expect(unsupportedFixtureResponse).toEqual({
|
||
body: 'This fixture intentionally declares Matroska without playable media bytes.\n',
|
||
bodyBytes: 75,
|
||
contentLength: '75',
|
||
contentType: 'video/matroska',
|
||
rendererProtocol: 'file:',
|
||
status: 200,
|
||
});
|
||
|
||
await importDashPlaylistFromText(
|
||
app,
|
||
buildDashPlaylist(fixtureServer.origin)
|
||
);
|
||
|
||
// Happy path: ClearKey EME decrypts and playback advances.
|
||
await channelItemByTitle(app.mainWindow, 'ClearKey DASH')
|
||
.first()
|
||
.click();
|
||
const video = app.mainWindow
|
||
.locator('app-web-player-view video')
|
||
.first();
|
||
await expect(video).toBeVisible({ timeout: 15_000 });
|
||
await expect
|
||
.poll(
|
||
() =>
|
||
video.evaluate(
|
||
(element: HTMLVideoElement) => element.currentTime
|
||
),
|
||
{ timeout: 20_000 }
|
||
)
|
||
.toBeGreaterThan(0.5);
|
||
await expect(
|
||
app.mainWindow.getByTestId('playback-diagnostic-banner')
|
||
).toBeHidden();
|
||
|
||
// Negative: an unsupported license type must not crash — it shows the
|
||
// DRM diagnostic instead.
|
||
await channelItemByTitle(app.mainWindow, 'Widevine DASH')
|
||
.first()
|
||
.click();
|
||
const banner = app.mainWindow.getByTestId('playback-diagnostic-banner');
|
||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||
await expect(banner).toContainText(
|
||
'This player does not support the stream’s DRM configuration.'
|
||
);
|
||
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);
|
||
const widevineDetails = banner.locator(
|
||
'[data-test-id="playback-diagnostic-details"]'
|
||
);
|
||
await widevineDetails.locator('summary').click();
|
||
await expect(widevineDetails).toContainText('drm-or-encryption');
|
||
await expect(widevineDetails).toContainText('Widevine');
|
||
const copyDiagnostic = banner.getByRole('button', {
|
||
name: 'Copy diagnostics',
|
||
exact: true,
|
||
});
|
||
await copyDiagnostic.click();
|
||
await expect(
|
||
banner.getByTestId('playback-copy-diagnostic-status')
|
||
).toHaveText('Diagnostics copied');
|
||
const drmReport = await app.electronApp.evaluate(({ clipboard }) =>
|
||
clipboard.readText()
|
||
);
|
||
expect(JSON.parse(drmReport)).toMatchObject({
|
||
app: 'IPTVnator',
|
||
runtime: 'electron',
|
||
code: 'drm-or-encryption',
|
||
drmSystems: ['widevine'],
|
||
});
|
||
for (const secret of [
|
||
fixtureServer.origin,
|
||
'license.example.com',
|
||
CLEARKEY_KEY,
|
||
CLEARKEY_KID,
|
||
]) {
|
||
expect(drmReport).not.toContain(secret);
|
||
}
|
||
|
||
await installPlaybackRecommendationLaunchCapture(app);
|
||
await channelItemByTitle(app.mainWindow, 'Unsupported MKV')
|
||
.first()
|
||
.click();
|
||
await expect(banner).toContainText(
|
||
/container is likely unsupported by the browser player/i,
|
||
{ timeout: 15_000 }
|
||
);
|
||
const mkvDetails = banner.locator(
|
||
'[data-test-id="playback-diagnostic-details"]'
|
||
);
|
||
await mkvDetails.locator('summary').click();
|
||
await expect(mkvDetails).toContainText('unsupported-container');
|
||
await expect(
|
||
banner.getByTestId('playback-copy-diagnostic-status')
|
||
).toBeEmpty();
|
||
await copyDiagnostic.click();
|
||
const mkvReport = JSON.parse(
|
||
await app.electronApp.evaluate(({ clipboard }) =>
|
||
clipboard.readText()
|
||
)
|
||
);
|
||
expect(mkvReport).toMatchObject({
|
||
code: 'unsupported-container',
|
||
drmSystems: [],
|
||
});
|
||
expect(mkvReport).not.toHaveProperty('runtimeSupport');
|
||
|
||
const mpvFallback = banner.locator(
|
||
'[data-test-id="playback-fallback-mpv"]'
|
||
);
|
||
const vlcFallback = banner.locator(
|
||
'[data-test-id="playback-fallback-vlc"]'
|
||
);
|
||
await expect(mpvFallback).toBeVisible();
|
||
await expect(vlcFallback).toBeVisible();
|
||
await expect(mpvFallback).toHaveClass(
|
||
/web-player-diagnostic__player-card--primary/
|
||
);
|
||
await expect(vlcFallback).not.toHaveClass(
|
||
/web-player-diagnostic__player-card--primary/
|
||
);
|
||
|
||
// Blank channel-level #EXTVLCOPT values resolve to `undefined` (not
|
||
// empty strings) since the playlist-level header fallback landed —
|
||
// absent means absent on the IPC boundary.
|
||
const expectedLaunches = [
|
||
{
|
||
args: [
|
||
`${fixtureServer.origin}/unsupported.mkv`,
|
||
'Unsupported MKV',
|
||
'',
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
],
|
||
player: 'mpv',
|
||
},
|
||
] satisfies CapturedExternalPlayerLaunch[];
|
||
expect(await getPlaybackRecommendationCapture(app)).toEqual({
|
||
closed: [],
|
||
completed: 0,
|
||
launches: [],
|
||
released: false,
|
||
});
|
||
|
||
await mpvFallback.click();
|
||
await expect
|
||
.poll(() => getPlaybackRecommendationCapture(app), {
|
||
timeout: 10_000,
|
||
})
|
||
.toEqual({
|
||
closed: [],
|
||
completed: 0,
|
||
launches: expectedLaunches,
|
||
released: false,
|
||
});
|
||
await expect(mpvFallback).toBeVisible();
|
||
await expect(vlcFallback).toBeVisible();
|
||
await expect(mpvFallback).toContainText('Opening MPV');
|
||
await expect(mpvFallback).toContainText('Opening player');
|
||
await expect(mpvFallback).toHaveAttribute('aria-busy', 'true');
|
||
await expect(vlcFallback).toHaveAttribute('aria-disabled', 'true');
|
||
await vlcFallback.evaluate((button: HTMLButtonElement) =>
|
||
button.click()
|
||
);
|
||
await expect
|
||
.poll(() => getPlaybackRecommendationCapture(app))
|
||
.toEqual({
|
||
closed: [],
|
||
completed: 0,
|
||
launches: expectedLaunches,
|
||
released: false,
|
||
});
|
||
const dock = app.mainWindow.locator('app-external-playback-dock');
|
||
await expect(dock).toContainText('Opening player');
|
||
await expect(dock.locator('.external-playback-dock')).toHaveAttribute(
|
||
'aria-busy',
|
||
'true'
|
||
);
|
||
|
||
await releasePlaybackRecommendationCapture(app);
|
||
await expect
|
||
.poll(() => getPlaybackRecommendationCapture(app), {
|
||
timeout: 10_000,
|
||
})
|
||
.toEqual({
|
||
closed: [],
|
||
completed: 1,
|
||
launches: expectedLaunches,
|
||
released: true,
|
||
});
|
||
await app.mainWindow.evaluate(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
requestAnimationFrame(() =>
|
||
requestAnimationFrame(() => resolve())
|
||
);
|
||
})
|
||
);
|
||
expect(await getPlaybackRecommendationCapture(app)).toEqual({
|
||
closed: [],
|
||
completed: 1,
|
||
launches: expectedLaunches,
|
||
released: true,
|
||
});
|
||
await expect(mpvFallback).toBeVisible();
|
||
await expect(mpvFallback).toContainText('Open MPV again');
|
||
await expect(mpvFallback).toContainText('Player started');
|
||
await expect(vlcFallback).toHaveClass(
|
||
/web-player-diagnostic__player-card--primary/
|
||
);
|
||
await expect(dock).toContainText('Player started');
|
||
const dockThemeStyles = await dock
|
||
.locator('.external-playback-dock')
|
||
.evaluate((element) => {
|
||
const body = element.ownerDocument.body;
|
||
const wasDark = body.classList.contains('dark-theme');
|
||
const read = () => {
|
||
const style = getComputedStyle(element);
|
||
return {
|
||
borderColor: style.borderColor,
|
||
color: style.color,
|
||
onSurface: style
|
||
.getPropertyValue('--app-heading-color')
|
||
.trim(),
|
||
surface: style
|
||
.getPropertyValue('--app-widget-bg')
|
||
.trim(),
|
||
};
|
||
};
|
||
body.classList.remove('dark-theme');
|
||
const light = read();
|
||
body.classList.add('dark-theme');
|
||
const dark = read();
|
||
body.classList.toggle('dark-theme', wasDark);
|
||
return { dark, light };
|
||
});
|
||
expect(dockThemeStyles.light.surface).not.toBe('');
|
||
expect(dockThemeStyles.dark.surface).not.toBe('');
|
||
expect(dockThemeStyles.light.onSurface).not.toBe('');
|
||
expect(dockThemeStyles.dark.onSurface).not.toBe('');
|
||
expect(dockThemeStyles.light).not.toEqual(dockThemeStyles.dark);
|
||
|
||
await vlcFallback.click();
|
||
const expectedBothLaunches = [
|
||
...expectedLaunches,
|
||
{ ...expectedLaunches[0], player: 'vlc' as const },
|
||
];
|
||
await expect
|
||
.poll(() => getPlaybackRecommendationCapture(app), {
|
||
timeout: 10_000,
|
||
})
|
||
.toEqual({
|
||
closed: ['e2e-recommended-mpv-1'],
|
||
completed: 2,
|
||
launches: expectedBothLaunches,
|
||
released: true,
|
||
});
|
||
await expect(mpvFallback).toBeVisible();
|
||
await expect(vlcFallback).toBeVisible();
|
||
await expect(vlcFallback).toContainText('Try VLC again');
|
||
await expect(vlcFallback).toContainText('External player error');
|
||
await expect(mpvFallback).toHaveClass(
|
||
/web-player-diagnostic__player-card--primary/
|
||
);
|
||
// A failure without an error code shows the translated generic
|
||
// status; the raw main-process detail stays available as a tooltip.
|
||
await expect(dock).toContainText('External player error');
|
||
await expect(
|
||
dock.locator('.external-playback-dock__status-text')
|
||
).toHaveAttribute('title', 'E2E player launch failed');
|
||
await expect(
|
||
dock.getByRole('button', { name: 'Dismiss' })
|
||
).toBeVisible();
|
||
await dock.getByRole('button', { name: 'Dismiss' }).click();
|
||
await expect(dock).toBeHidden();
|
||
|
||
// Playlist-level custom headers must cross the IPC boundary when the
|
||
// channel itself carries no #EXTVLCOPT values (#1221): set a
|
||
// User-Agent on the source, relaunch the MPV fallback, and expect the
|
||
// captured launch to carry it.
|
||
await openSources(app.mainWindow);
|
||
const sourceDialog = await openSourceEditor(
|
||
app.mainWindow,
|
||
'Imported as text'
|
||
);
|
||
await updateSourceDialog(sourceDialog, {
|
||
userAgent: 'Playlist Agent E2E/1.0',
|
||
});
|
||
await saveSourceDialog(app.mainWindow, sourceDialog);
|
||
await sourceRowByTitle(app.mainWindow, 'Imported as text')
|
||
.first()
|
||
.click();
|
||
await waitForM3uCatalog(app.mainWindow);
|
||
|
||
await channelItemByTitle(app.mainWindow, 'Unsupported MKV')
|
||
.first()
|
||
.click();
|
||
await expect(banner).toContainText(
|
||
/container is likely unsupported by the browser player/i,
|
||
{ timeout: 15_000 }
|
||
);
|
||
await expect(mpvFallback).toBeVisible();
|
||
await mpvFallback.click();
|
||
const expectedPlaylistHeaderLaunch = {
|
||
args: [
|
||
`${fixtureServer.origin}/unsupported.mkv`,
|
||
'Unsupported MKV',
|
||
'',
|
||
'Playlist Agent E2E/1.0',
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
],
|
||
player: 'mpv',
|
||
} satisfies CapturedExternalPlayerLaunch;
|
||
await expect
|
||
.poll(() => getPlaybackRecommendationCapture(app), {
|
||
timeout: 10_000,
|
||
})
|
||
.toEqual({
|
||
closed: ['e2e-recommended-mpv-1'],
|
||
completed: 3,
|
||
launches: [
|
||
...expectedBothLaunches,
|
||
expectedPlaylistHeaderLaunch,
|
||
],
|
||
released: true,
|
||
});
|
||
} finally {
|
||
await releasePlaybackRecommendationCapture(app).catch(() => undefined);
|
||
await closeElectronApp(app);
|
||
await fixtureServer.close();
|
||
}
|
||
});
|