mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(xtream): preserve content identity in PWA collections (#1870)
* fix(xtream): invalidate detail initialization on close * fix(xtream): preserve typed PWA collection identity * fix(xtream): complete typed live collection actions * test(xtream): cover PWA collection identity through reloads * fix(xtream): keep typed collection actions local * fix(xtream): tolerate quota limits during collection migration * fix(xtream): prioritize requested collection saves * fix(xtream): align collection identity with current playback contracts * test(xtream): confirm playback before collection history assertions
This commit is contained in:
1 parent
0060330b5e
commit
98784815b1
31 files changed
+2349
-551
No files matched your search
@@ -0,0 +1,368 @@
|
||||
import { expect, type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type ContentType = 'movie' | 'series' | 'live';
|
||||
type CatalogItem = {
|
||||
category_id: string | number;
|
||||
stream_id?: number;
|
||||
series_id?: number;
|
||||
};
|
||||
type Detail = {
|
||||
info: Record<string, unknown>;
|
||||
movie_data: Record<string, unknown>;
|
||||
episodes?: Record<string, Array<Record<string, unknown>>>;
|
||||
};
|
||||
|
||||
export const collisionItems = [
|
||||
{ type: 'movie', label: 'Movies', title: 'Collision Movie' },
|
||||
{ type: 'series', label: 'Series', title: 'Collision Series' },
|
||||
{ type: 'live', label: 'Live TV', title: 'Collision Live' },
|
||||
] as const;
|
||||
|
||||
export type CollisionItem = (typeof collisionItems)[number];
|
||||
|
||||
/** Keep mock shapes, with colliding IDs and native-playable VOD extensions. */
|
||||
export async function interceptCollidingXtreamItems(
|
||||
page: Page,
|
||||
request: APIRequestContext,
|
||||
mockServer: string
|
||||
): Promise<Record<ContentType, string>> {
|
||||
async function fetchFixture<T>(
|
||||
action: string,
|
||||
parameters: Record<string, string> = {}
|
||||
): Promise<T> {
|
||||
const url = new URL(`${mockServer}/player_api.php`);
|
||||
url.search = new URLSearchParams({
|
||||
username: 'minimal',
|
||||
password: 'minimal',
|
||||
action,
|
||||
...parameters,
|
||||
}).toString();
|
||||
const response = await request.get(url.toString());
|
||||
expect(response.ok()).toBeTruthy();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const [[movie], [series], [live]] = await Promise.all([
|
||||
fetchFixture<CatalogItem[]>('get_vod_streams'),
|
||||
fetchFixture<CatalogItem[]>('get_series'),
|
||||
fetchFixture<CatalogItem[]>('get_live_streams'),
|
||||
]);
|
||||
const [movieDetails, seriesDetails] = await Promise.all([
|
||||
fetchFixture<Detail>('get_vod_info', {
|
||||
vod_id: String(movie.stream_id),
|
||||
}),
|
||||
fetchFixture<Detail>('get_series_info', {
|
||||
series_id: String(series.series_id),
|
||||
}),
|
||||
]);
|
||||
for (const episodes of Object.values(seriesDetails.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode['container_extension'] = 'mp4';
|
||||
}
|
||||
}
|
||||
const payloads: Record<string, unknown> = {
|
||||
get_vod_streams: [
|
||||
{
|
||||
...movie,
|
||||
stream_id: 103,
|
||||
name: 'Collision Movie',
|
||||
container_extension: 'mp4',
|
||||
},
|
||||
],
|
||||
get_series: [{ ...series, series_id: 103, name: 'Collision Series' }],
|
||||
get_live_streams: [{ ...live, stream_id: 103, name: 'Collision Live' }],
|
||||
get_vod_info: {
|
||||
...movieDetails,
|
||||
info: { ...movieDetails.info, name: 'Collision Movie' },
|
||||
movie_data: {
|
||||
...movieDetails.movie_data,
|
||||
stream_id: 103,
|
||||
name: 'Collision Movie',
|
||||
container_extension: 'mp4',
|
||||
},
|
||||
},
|
||||
get_series_info: {
|
||||
...seriesDetails,
|
||||
info: { ...seriesDetails.info, name: 'Collision Series' },
|
||||
},
|
||||
get_short_epg: { epg_listings: [] },
|
||||
get_simple_data_table: { epg_listings: [] },
|
||||
};
|
||||
|
||||
// Fixtures may include remote artwork. Keep this regression fully local.
|
||||
await page.route('https://**', (route) => route.abort());
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const action = new URL(route.request().url()).searchParams.get(
|
||||
'action'
|
||||
);
|
||||
if (!action || !(action in payloads)) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: { action, payload: payloads[action] } });
|
||||
});
|
||||
|
||||
return {
|
||||
movie: String(movie.category_id),
|
||||
series: String(series.category_id),
|
||||
live: String(live.category_id),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fail initial attempts, then serve only repository-owned synthetic media. */
|
||||
export async function routeCollisionMedia(
|
||||
page: Page,
|
||||
mockServer: string
|
||||
): Promise<() => void> {
|
||||
let playable = false;
|
||||
const webm = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
const transportStream = readFileSync(
|
||||
join(__dirname, '../../xtream-mock-server/src/fixtures/live.mpegts')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === mockServer &&
|
||||
/^\/(live|movie|series)\//.test(url.pathname),
|
||||
async (route) => {
|
||||
if (!playable) {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
body: 'Fixture unavailable',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith('.m3u8')) {
|
||||
await route.fulfill({
|
||||
contentType: 'application/vnd.apple.mpegurl',
|
||||
body: [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXTINF:6,',
|
||||
`${mockServer}/live/collection-fixture.ts`,
|
||||
'#EXT-X-ENDLIST',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path.endsWith('.ts')) {
|
||||
await route.fulfill({
|
||||
contentType: 'video/mp2t',
|
||||
body: transportStream,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Native media reads use ranges, even when the clip is small.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = webm.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, webm.length - Number(range[2]))
|
||||
: 0;
|
||||
const end =
|
||||
range?.[1] && range[2]
|
||||
? Math.min(Number(range[2]), last)
|
||||
: last;
|
||||
await route.fulfill({
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${webm.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: webm.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
playable = true;
|
||||
};
|
||||
}
|
||||
|
||||
export async function openCollisionCatalogItem(
|
||||
page: Page,
|
||||
item: CollisionItem,
|
||||
categoryId: string
|
||||
): Promise<void> {
|
||||
await page.getByRole('link', { name: item.label, exact: true }).click();
|
||||
await page
|
||||
.locator(
|
||||
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible`
|
||||
)
|
||||
.click();
|
||||
if (item.type !== 'live') {
|
||||
await page
|
||||
.locator('.category-content-layout mat-card')
|
||||
.filter({ hasText: item.title })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: item.title, exact: true })
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export function collisionCollectionItem(page: Page, item: CollisionItem) {
|
||||
return item.type === 'live'
|
||||
? page.locator('app-channel-list-item').filter({
|
||||
has: page.locator('.channel-name', { hasText: item.title }),
|
||||
})
|
||||
: page.locator('app-content-card').filter({
|
||||
has: page.getByRole('heading', { name: item.title, exact: true }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function addCollisionFavorite(
|
||||
page: Page,
|
||||
item: CollisionItem,
|
||||
categoryId: string
|
||||
): Promise<void> {
|
||||
await openCollisionCatalogItem(page, item, categoryId);
|
||||
if (item.type === 'live') {
|
||||
const channel = collisionCollectionItem(page, item);
|
||||
const favorite = channel.locator('.favorite-button').first();
|
||||
// Other content sharing this ID must not preselect the channel.
|
||||
await expect(favorite.locator('mat-icon')).toHaveText('star_outline');
|
||||
await channel.hover();
|
||||
await favorite.click();
|
||||
await expect(favorite.locator('mat-icon')).toHaveText('star');
|
||||
return;
|
||||
}
|
||||
const addButton = page.getByRole('button', { name: /add to favorites/i });
|
||||
await expect(addButton).toBeVisible();
|
||||
await addButton.click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /remove from favorites/i })
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function playCollisionItem(
|
||||
page: Page,
|
||||
item: CollisionItem,
|
||||
categoryId: string,
|
||||
confirmPlayback = true
|
||||
): Promise<void> {
|
||||
await openCollisionCatalogItem(page, item, categoryId);
|
||||
if (item.type === 'live') {
|
||||
await collisionCollectionItem(page, item).click();
|
||||
} else {
|
||||
await page
|
||||
.getByTestId(
|
||||
item.type === 'movie'
|
||||
? 'vod-primary-action'
|
||||
: 'series-quick-start'
|
||||
)
|
||||
.click();
|
||||
}
|
||||
await expect(page.locator('app-web-player-view')).toBeVisible();
|
||||
if (confirmPlayback) {
|
||||
const video = page.locator('app-web-player-view video').first();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
video.evaluate(
|
||||
(media: HTMLVideoElement) =>
|
||||
!media.paused && media.readyState >= 2
|
||||
),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
const start = await video.evaluate(
|
||||
(media: HTMLVideoElement) => media.currentTime
|
||||
);
|
||||
// Observe real decoder progress beyond the two-second history gate.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
video.evaluate(
|
||||
(media: HTMLVideoElement, initial) =>
|
||||
media.paused ? 0 : media.currentTime - initial,
|
||||
start
|
||||
),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.toBeGreaterThanOrEqual(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCollisionFavorite(
|
||||
page: Page,
|
||||
item: CollisionItem,
|
||||
remainingTypeCount: number
|
||||
): Promise<void> {
|
||||
if (remainingTypeCount > 1) {
|
||||
await selectCollectionType(page, item);
|
||||
}
|
||||
const row = collisionCollectionItem(page, item);
|
||||
await row.hover();
|
||||
await row
|
||||
.locator(item.type === 'live' ? '.favorite-button' : '.remove-button')
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function selectCollectionType(
|
||||
page: Page,
|
||||
item: CollisionItem
|
||||
): Promise<void> {
|
||||
await page.getByRole('radio', { name: item.label, exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectCollisionCollection(
|
||||
page: Page,
|
||||
items: readonly CollisionItem[]
|
||||
): Promise<void> {
|
||||
// Full navigation/reload passes the startup splash before mounting the list.
|
||||
await expect(page.locator('app-unified-collection-page')).toBeAttached({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const toggles = page.getByRole('radio', {
|
||||
name: /^(Movies|Series|Live TV)$/,
|
||||
});
|
||||
await expect(toggles).toHaveCount(items.length > 1 ? items.length : 0);
|
||||
for (const item of items) {
|
||||
if (items.length > 1) {
|
||||
await selectCollectionType(page, item);
|
||||
}
|
||||
await expect(collisionCollectionItem(page, item)).toBeVisible();
|
||||
}
|
||||
for (const removed of collisionItems.filter(
|
||||
(item) => !items.includes(item)
|
||||
)) {
|
||||
await expect(collisionCollectionItem(page, removed)).toHaveCount(0);
|
||||
await expect(toggles.filter({ hasText: removed.label })).toHaveCount(0);
|
||||
}
|
||||
if (items.length === 0) {
|
||||
await expect(page.locator('app-empty-state')).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectCollectionDetailRoundTrip(
|
||||
page: Page,
|
||||
item: CollisionItem
|
||||
): Promise<void> {
|
||||
await selectCollectionType(page, item);
|
||||
const collectionUrl = page.url();
|
||||
await collisionCollectionItem(page, item).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: item.title, exact: true })
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(collectionUrl);
|
||||
await page.locator('[data-test-id="workspace-header-back"]').click();
|
||||
await expect(page).toHaveURL(collectionUrl);
|
||||
await expect(collisionCollectionItem(page, item)).toBeVisible();
|
||||
}
|
||||
@@ -15,7 +15,20 @@ import {
|
||||
DEFAULT_USERNAME,
|
||||
interceptXtreamRequests,
|
||||
MOCK_SERVER,
|
||||
selectWebPlayer,
|
||||
} from './xtream-series-playback.fixture';
|
||||
import {
|
||||
addCollisionFavorite,
|
||||
collisionCollectionItem,
|
||||
collisionItems,
|
||||
expectCollectionDetailRoundTrip,
|
||||
expectCollisionCollection,
|
||||
interceptCollidingXtreamItems,
|
||||
playCollisionItem,
|
||||
removeCollisionFavorite,
|
||||
routeCollisionMedia,
|
||||
selectCollectionType,
|
||||
} from './xtream-collection-helpers';
|
||||
|
||||
/**
|
||||
* Xtream Codes E2E Tests
|
||||
@@ -413,6 +426,108 @@ test('@xtream add portal and see it in the playlist list', async ({ page }) => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('@xtream @collections PWA favorites keep colliding movie, series and live IDs independent after reload and removal', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const categories = await interceptCollidingXtreamItems(
|
||||
page,
|
||||
request,
|
||||
MOCK_SERVER
|
||||
);
|
||||
await addXtreamPortal(page, {
|
||||
name: 'Collision Favorites Portal',
|
||||
username: 'minimal',
|
||||
password: 'minimal',
|
||||
});
|
||||
const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, '');
|
||||
|
||||
for (const item of collisionItems) {
|
||||
await addCollisionFavorite(page, item, categories[item.type]);
|
||||
}
|
||||
|
||||
await page.goto(`${playlistPath}/favorites`);
|
||||
await expectCollisionCollection(page, collisionItems);
|
||||
await page.reload();
|
||||
await expectCollisionCollection(page, collisionItems);
|
||||
await expectCollectionDetailRoundTrip(page, collisionItems[0]);
|
||||
await expectCollectionDetailRoundTrip(page, collisionItems[1]);
|
||||
|
||||
for (let index = 0; index < collisionItems.length; index++) {
|
||||
const item = collisionItems[index];
|
||||
await removeCollisionFavorite(
|
||||
page,
|
||||
item,
|
||||
collisionItems.length - index
|
||||
);
|
||||
await expect(collisionCollectionItem(page, item)).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expectCollisionCollection(page, collisionItems.slice(index + 1));
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream @collections PWA recent items require confirmed playback and retain colliding content IDs', async ({
|
||||
page,
|
||||
request,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName !== 'chromium',
|
||||
'Synthetic live media uses Chromium codecs'
|
||||
);
|
||||
test.setTimeout(90_000);
|
||||
const categories = await interceptCollidingXtreamItems(
|
||||
page,
|
||||
request,
|
||||
MOCK_SERVER
|
||||
);
|
||||
const allowPlayback = await routeCollisionMedia(page, MOCK_SERVER);
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page, {
|
||||
name: 'Collision Recent Portal',
|
||||
username: 'minimal',
|
||||
password: 'minimal',
|
||||
});
|
||||
const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, '');
|
||||
|
||||
for (const item of collisionItems) {
|
||||
const failedMedia = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().startsWith(`${MOCK_SERVER}/${item.type}/`) &&
|
||||
response.status() === 503
|
||||
);
|
||||
await playCollisionItem(page, item, categories[item.type], false);
|
||||
await failedMedia;
|
||||
await page.goto(`${playlistPath}/recent`);
|
||||
await expectCollisionCollection(page, []);
|
||||
}
|
||||
|
||||
allowPlayback();
|
||||
for (let index = 0; index < collisionItems.length; index++) {
|
||||
const item = collisionItems[index];
|
||||
await playCollisionItem(page, item, categories[item.type]);
|
||||
await page.goto(`${playlistPath}/recent`);
|
||||
await expectCollisionCollection(
|
||||
page,
|
||||
collisionItems.slice(0, index + 1)
|
||||
);
|
||||
}
|
||||
await page.reload();
|
||||
await expectCollisionCollection(page, collisionItems);
|
||||
await expectCollectionDetailRoundTrip(page, collisionItems[0]);
|
||||
await expectCollectionDetailRoundTrip(page, collisionItems[1]);
|
||||
|
||||
await selectCollectionType(page, collisionItems[0]);
|
||||
const movie = collisionCollectionItem(page, collisionItems[0]);
|
||||
await movie.hover();
|
||||
await movie.locator('.remove-button').click();
|
||||
await expect(movie).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expectCollisionCollection(page, collisionItems.slice(1));
|
||||
});
|
||||
|
||||
test('@xtream playlist details edit is retained in the PWA browser context', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in new issue
Block a user