Merge origin/master into claude/design-handoff-prompt-d0aee4

This commit is contained in:
4gray committed 2026-10-13 09:12:37 +02:00
commit 845d295474
78 files changed
+4367 -276

No files matched your search

@@ -0,0 +1,200 @@
import type { APIRequestContext, Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
openSettings,
openSettingsSection,
openWorkspaceSection,
resetMockServers,
saveSettings,
test,
waitForXtreamImportToFinish,
xtreamMockServer,
} from './electron-test-fixtures';
import { installEmbeddedMpvSessionCapture } from './embedded-mpv-frame-copy-packaged-fixtures';
import {
fetchXtreamSeriesFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
/**
* Issue #1902: an episode whose connection drops reaches mpv as EOF in the
* middle of the episode, and series autoplay used to take that for the end
* and start the next episode. The `streamdrop` mock cuts every episode a
* third of the way into its 60 s clip and refuses it for a few seconds, so
* ffmpeg's own reconnect fails and only the app-level reconnect can resume.
* Needs the native-view Embedded MPV runtime; skips where it is missing.
*/
const credentials = { password: 'streamdrop', username: 'streamdrop' };
/** Past the ~21 s cut: the resumed episode has played on. */
const RESUMED_POSITION_SECONDS = 25;
/** Keyframe rounding and the 500 ms poll before the drop. */
const RESUME_TOLERANCE_SECONDS = 3;
type SessionSample = {
episodeId: number | null;
positionSeconds: number;
reconnecting: boolean;
status: string;
};
async function fetchSeasonOneEpisodeIds(
request: APIRequestContext,
seriesId: number
): Promise<number[]> {
const params = new URLSearchParams({
action: 'get_series_info',
series_id: String(seriesId),
...credentials,
});
const response = await request.get(
`${xtreamMockServer}/player_api.php?${params}`
);
expect(response.ok()).toBeTruthy();
const info = (await response.json()) as {
episodes?: Record<string, { id: string; episode_num: number }[]>;
};
return [...(info.episodes?.['1'] ?? [])]
.sort((a, b) => a.episode_num - b.episode_num)
.map((episode) => Number(episode.id));
}
/**
* Session updates in arrival order, each tagged with the episode its session
* last loaded: the `loading` update carries the requested URL, while later
* snapshots report mpv's `path`, which not every engine fills in reliably.
*/
function readSessions(page: Page): Promise<SessionSample[]> {
return page.evaluate(() => {
const episodeBySession = new Map<string, number>();
return (window.__packagedEmbeddedMpvSessions ?? []).map((session) => {
const match = /\/series\/[^/]+\/[^/]+\/(\d+)\.\w+$/.exec(
session.streamUrl
);
if (match) {
episodeBySession.set(session.id, Number(match[1]));
}
return {
episodeId: episodeBySession.get(session.id) ?? null,
positionSeconds: session.positionSeconds,
reconnecting: !!session.reconnect,
status: session.status,
};
});
});
}
/**
* What followed the first episode's mid-episode EOF, in update order. Every
* position played after the drop must stay near the drop: a reload that
* restarted the episode would otherwise reach the resumed position as well.
* A zero is the engine's placeholder before the reload's first `time-pos`.
*/
function outcomeAfterDrop(
sessions: SessionSample[],
[first, second]: number[]
): 'pending' | 'resumed' | 'restarted' | 'skipped' {
const dropped = sessions.findIndex(
(session) => session.episodeId === first && session.status === 'ended'
);
if (dropped < 0) {
return 'pending';
}
const dropPosition = sessions[dropped].positionSeconds;
for (const session of sessions.slice(dropped)) {
if (session.episodeId === second) {
return 'skipped';
}
if (session.episodeId !== first || session.status !== 'playing') {
continue;
}
if (
session.positionSeconds > 0 &&
session.positionSeconds < dropPosition - RESUME_TOLERANCE_SECONDS
) {
return 'restarted';
}
if (session.positionSeconds >= RESUMED_POSITION_SECONDS) {
return 'resumed';
}
}
return 'pending';
}
test('@playback @xtream @electron @embedded-mpv resumes an episode whose connection drops instead of skipping to the next one', async ({
dataDir,
request,
}) => {
test.setTimeout(180_000);
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamSeriesFixture(request, credentials);
const series = fixture.items[0];
if (!series) {
throw new Error('The stream-drop scenario has no series.');
}
const episodeIds = await fetchSeasonOneEpisodeIds(
request,
Number(series.series_id)
);
expect(episodeIds).toHaveLength(3);
const app = await launchElectronApp(dataDir, {
env: {
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: '0',
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
},
});
try {
const support = await app.mainWindow.evaluate(() =>
window.electron.getEmbeddedMpvSupport()
);
test.skip(
!support.supported || support.engine !== 'native',
`Native-view Embedded MPV unavailable: ${JSON.stringify(support)}`
);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await app.mainWindow.getByTestId('select-video-player').click();
await app.mainWindow.getByTestId('embedded-mpv').click();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: 'Stream Drop',
});
await waitForXtreamImportToFinish(app.mainWindow);
await installEmbeddedMpvSessionCapture(app);
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await clickGridListCardByTitle(app.mainWindow, getXtreamTitle(series));
const episode = app.mainWindow
.locator('.episode-card, .episode-list-item')
.first();
await expect(episode).toBeVisible({ timeout: 20_000 });
await episode.click();
await expect
.poll(
async () =>
outcomeAfterDrop(
await readSessions(app.mainWindow),
episodeIds
),
{ timeout: 90_000 }
)
.not.toBe('pending');
const sessions = await readSessions(app.mainWindow);
expect(outcomeAfterDrop(sessions, episodeIds)).toBe('resumed');
// The drop was shown as a reconnect, not as the episode's end.
expect(sessions.some((session) => session.reconnecting)).toBe(true);
} finally {
await closeElectronApp(app);
}
});
@@ -60,7 +60,7 @@ export const SEARCH_JOURNEY_PORTAL_NAME = 'Journey search portal';
* matches 170 series titles, more than the first page of 100.
*/
export const SEARCH_JOURNEY_QUERY = 'system';
/** Well below the shell's 350 ms input debounce, like steady typing. */
/** Well below the shell's 300 ms input debounce, like steady typing. */
export const SEARCH_JOURNEY_KEY_DELAY_MS = 100;
const QUIET_MS = 1_000;
const QUIET_POLL_MS = 100;
@@ -0,0 +1,301 @@
import type { Locator, Page } from '@playwright/test';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
import {
closeElectronApp,
electronMainPath,
expect,
type LaunchedElectronApp,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openSources,
openWorkspaceSection,
restartElectronApp,
sourceRowByTitle,
test,
waitForM3uCatalog,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import {
expectRendererReloadedOnRoute,
reloadFromMainProcess,
} from './renderer-reload.support';
const SOURCE_TITLE = 'group-management-source';
const HIDDEN_GROUPS = ['Movies', 'Sports'];
const VISIBLE_HIDDEN_GROUPS_KEY = '__e2eVisibleHiddenGroups';
const RECONCILE_CHANNEL = 'EPG_RECONCILE_SOURCES';
interface ReconcileScript {
calls: number;
gate: Promise<void>;
release: () => void;
}
type ReconcileGlobal = typeof globalThis & {
__e2eReconcileScript?: ReconcileScript;
};
// Serialized into the test bootstrap; all runtime dependencies are arguments.
function installReconcileScript(
{ ipcMain }: Pick<typeof import('electron'), 'ipcMain'>,
channel: string
): void {
const register = ipcMain.handle;
const scope = globalThis as ReconcileGlobal;
ipcMain.handle = function (name, listener) {
if (name !== channel) {
return register.call(this, name, listener);
}
return register.call(this, name, async (...args) => {
const script = scope.__e2eReconcileScript;
if (script) {
script.calls += 1;
if (script.calls === 1) return { success: false };
await script.gate;
}
return listener(...args);
});
};
}
function launchWithReconcileScript(
dataDir: string
): Promise<LaunchedElectronApp> {
const entryPoint = join(dataDir, 'reconcile-script.cjs');
writeFileSync(
entryPoint,
[
`(${installReconcileScript.toString()})(require('electron'), ${JSON.stringify(RECONCILE_CHANNEL)});`,
`require(${JSON.stringify(electronMainPath)});`,
].join('\n')
);
return launchElectronApp(dataDir, { entryPoint });
}
/**
* The next renderer's startup reconciliation fails, and the retry the
* playlist inventory awaits is parked until `release`: the M3U route then
* activates (its settings resolver has finished) while the store still has no
* playlists.
*/
async function delayPlaylistInventory(app: LaunchedElectronApp) {
await app.electronApp.evaluate(() => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
(globalThis as ReconcileGlobal).__e2eReconcileScript = {
calls: 0,
gate,
release,
};
});
return {
waitUntilParked: () =>
expect
.poll(() =>
app.electronApp.evaluate(
() =>
(globalThis as ReconcileGlobal).__e2eReconcileScript
?.calls ?? 0
)
)
.toBeGreaterThan(1),
release: () =>
app.electronApp.evaluate(() => {
const scope = globalThis as ReconcileGlobal;
const script = scope.__e2eReconcileScript;
delete scope.__e2eReconcileScript;
script?.release();
}),
};
}
const CHANNELS = ['News', 'Sports', 'Movies'].flatMap((group) =>
[1, 2].map((index) => ({
groupTitle: group,
name: `${group} Channel ${index}`,
url: `https://streams.example.test/${group.toLowerCase()}-${index}.m3u8`,
}))
);
function groupNavLabels(page: Page): Locator {
return page.locator('app-groups-view .group-nav-item .nav-item-label');
}
async function openGroupManagement(page: Page): Promise<Locator> {
await page
.getByRole('button', { name: 'Manage groups', exact: true })
.click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog.locator('.group-item')).toHaveCount(3);
return dialog;
}
function groupCheckbox(dialog: Locator, group: string): Locator {
return dialog
.locator('.group-item')
.filter({ hasText: group })
.getByRole('checkbox');
}
async function storedHiddenGroupTitles(page: Page): Promise<unknown> {
return page.evaluate(async () => {
const playlistId = location.pathname.match(
/\/workspace\/playlists\/([^/]+)/
)?.[1];
if (!playlistId) throw new Error('M3U playlist route is missing');
const api = (window as unknown as { electron: ElectronBridgeApi })
.electron;
const playlist = (await api.dbGetAppPlaylist(playlistId)) as {
hiddenGroupTitles?: unknown;
} | null;
const hidden = playlist?.hiddenGroupTitles;
return Array.isArray(hidden) ? [...hidden].sort() : hidden;
});
}
/** Imports the playlist and hides {@link HIDDEN_GROUPS} in Manage groups. */
async function importAndHideGroups(
app: LaunchedElectronApp,
dataDir: string
): Promise<void> {
const filePath = writeTemporaryM3uFile(
dataDir,
`${SOURCE_TITLE}.m3u`,
CHANNELS
);
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Groups');
await expect(groupNavLabels(app.mainWindow)).toHaveText([
'Movies',
'News',
'Sports',
]);
const dialog = await openGroupManagement(app.mainWindow);
for (const group of HIDDEN_GROUPS) {
await groupCheckbox(dialog, group).uncheck();
}
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
await expect
.poll(() => storedHiddenGroupTitles(app.mainWindow))
.toEqual(HIDDEN_GROUPS);
}
/**
* Records, from the start of every new document, each hidden group the
* groups rail ever renders visibly — including before the startup inventory
* publishes the playlist.
*/
async function recordVisibleHiddenGroups(
app: LaunchedElectronApp
): Promise<void> {
await app.electronApp.context().addInitScript(
({ hiddenGroups, key }) => {
const seen: string[] = [];
Reflect.set(window, key, seen);
const check = () => {
for (const item of document.querySelectorAll(
'app-groups-view .group-nav-item'
)) {
const group = item.getAttribute('data-group-key') ?? '';
if (
hiddenGroups.includes(group) &&
!seen.includes(group) &&
item.checkVisibility()
) {
seen.push(group);
}
}
};
new MutationObserver(check).observe(document, {
attributes: true,
attributeFilter: ['class', 'style'],
childList: true,
subtree: true,
});
},
{ hiddenGroups: HIDDEN_GROUPS, key: VISIBLE_HIDDEN_GROUPS_KEY }
);
}
test.describe('Electron M3U group management', () => {
test('keeps groups hidden in Manage groups after a restart (#1894)', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await importAndHideGroups(app, dataDir);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, SOURCE_TITLE)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Groups');
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
// The dialog opens from the restored state, so saving it again
// cannot silently unhide the groups.
const reopened = await openGroupManagement(app.mainWindow);
await expect(groupCheckbox(reopened, 'News')).toBeChecked();
await expect(groupCheckbox(reopened, 'Movies')).not.toBeChecked();
await expect(groupCheckbox(reopened, 'Sports')).not.toBeChecked();
await reopened
.getByRole('button', { name: 'Close', exact: true })
.click();
await expect(reopened).toBeHidden();
} finally {
await closeElectronApp(app);
}
});
test('never shows hidden groups when the Groups route opens before the playlist inventory', async ({
dataDir,
}) => {
const app = await launchWithReconcileScript(dataDir);
try {
await importAndHideGroups(app, dataDir);
await recordVisibleHiddenGroups(app);
const inventory = await delayPlaylistInventory(app);
// A reload boots a fresh store straight on this route.
await reloadFromMainProcess(app);
await inventory.waitUntilParked();
// The route session has read the playlist and published its
// channels, still behind the startup screen, before the store
// has the playlist the hidden groups belong to.
const rail = app.mainWindow.locator(
'app-groups-view .group-nav-item'
);
await expect(rail).toHaveCount(3);
await expect(rail.first()).toBeHidden();
await inventory.release();
await expectRendererReloadedOnRoute(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/groups$/
);
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
expect(
await app.mainWindow.evaluate(
(key) => Reflect.get(window, key),
VISIBLE_HIDDEN_GROUPS_KEY
)
).toEqual([]);
} finally {
await closeElectronApp(app);
}
});
});
@@ -34,7 +34,7 @@ export const SEARCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
export const SEARCH_JOURNEY_QUERY_METHOD = 'dbGlobalSearch';
/**
* Longest accepted gap between two keydowns. Below the shell's 350 ms input
* Longest accepted gap between two keydowns. Below the shell's 300 ms input
* debounce, so a late key on a busy machine cannot let an intermediate term
* run a query that steady typing would not.
*/