mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
Merge remote-tracking branch 'origin/master' into agent/download-season-queue
# Conflicts: # apps/electron-backend/src/app/events/database/download-requests.ts # libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts # libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts # libs/ui/components/src/lib/season-container/episode-download.util.ts # libs/ui/components/src/lib/season-container/episode-utils.spec.ts # libs/ui/components/src/lib/season-container/season-container.component.spec.ts # libs/ui/components/src/lib/season-container/season-container.component.ts
This commit is contained in:
commit
31ac306bb0
243 files changed
+12935
-1106
No files matched your search
@@ -0,0 +1,117 @@
|
||||
import { mkdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
createInterruptedRangeServer,
|
||||
INTERRUPTED_RANGE_SERVER_ETAG,
|
||||
startDownload,
|
||||
} from './downloads.e2e-support';
|
||||
|
||||
async function openDownloadsPage(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Open downloads' }).click();
|
||||
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
|
||||
}
|
||||
|
||||
async function getPlaylistId(page: Page, title: string): Promise<string> {
|
||||
const playlists = await page.evaluate(
|
||||
async () => (await window.electron?.dbGetAppPlaylistMetas?.()) ?? []
|
||||
);
|
||||
const playlist = playlists.find((entry) => entry.title === title);
|
||||
expect(playlist, `playlist "${title}" should exist`).toBeDefined();
|
||||
return playlist?._id ?? '';
|
||||
}
|
||||
|
||||
test.describe('Electron download reliability', () => {
|
||||
test('@downloads @electron retains a network-interrupted partial and retries it with HTTP Range', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const rangeServer = await createInterruptedRangeServer();
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Reset Portal',
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openDownloadsPage(app.mainWindow);
|
||||
|
||||
const downloadsDir = join(dataDir, 'e2e-reset-downloads');
|
||||
mkdirSync(downloadsDir, { recursive: true });
|
||||
await app.electronApp.evaluate(({ dialog }, folder) => {
|
||||
dialog.showOpenDialog = async () =>
|
||||
({
|
||||
canceled: false,
|
||||
filePaths: [folder],
|
||||
}) as Awaited<ReturnType<typeof dialog.showOpenDialog>>;
|
||||
}, downloadsDir);
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Change Folder' })
|
||||
.click();
|
||||
|
||||
const playlistId = await getPlaylistId(
|
||||
app.mainWindow,
|
||||
'Reset Portal'
|
||||
);
|
||||
const downloadId = await startDownload(app.mainWindow, {
|
||||
playlistId,
|
||||
xtreamId: 9801,
|
||||
contentType: 'vod',
|
||||
title: 'E2E Reset Movie',
|
||||
url: rangeServer.url,
|
||||
downloadFolder: downloadsDir,
|
||||
});
|
||||
const item = app.mainWindow.getByTestId(
|
||||
`download-queue-item-${downloadId}`
|
||||
);
|
||||
await expect(item.locator('.download-queue__status')).toContainText(
|
||||
'Failed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await expect(item.locator('.download-queue__error')).toContainText(
|
||||
'DOWNLOAD_NETWORK_INTERRUPTED (ECONNRESET)'
|
||||
);
|
||||
|
||||
const partialPath = join(downloadsDir, 'E2E Reset Movie.mp4.part');
|
||||
expect(statSync(partialPath).size).toBe(
|
||||
rangeServer.interruptedBytes
|
||||
);
|
||||
|
||||
await item
|
||||
.getByRole('button', { name: 'Retry E2E Reset Movie' })
|
||||
.click();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId(
|
||||
`download-library-movie-${downloadId}`
|
||||
)
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const resumeRequest = rangeServer.requests.find(
|
||||
(entry) => entry.range
|
||||
);
|
||||
expect(resumeRequest?.range).toBe(
|
||||
`bytes=${rangeServer.interruptedBytes}-`
|
||||
);
|
||||
expect(resumeRequest?.ifRange).toBe(INTERRUPTED_RANGE_SERVER_ETAG);
|
||||
const finalFile = readFileSync(
|
||||
join(downloadsDir, 'E2E Reset Movie.mp4')
|
||||
);
|
||||
expect(finalFile.equals(rangeServer.payload)).toBe(true);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await rangeServer.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,10 @@ interface ThrottledRangeServer {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface InterruptedRangeServer extends ThrottledRangeServer {
|
||||
interruptedBytes: number;
|
||||
}
|
||||
|
||||
interface TruncatedDownloadServer {
|
||||
close: () => Promise<void>;
|
||||
payload: Buffer;
|
||||
@@ -39,6 +43,7 @@ interface TruncatedDownloadServer {
|
||||
}
|
||||
|
||||
export const RANGE_SERVER_ETAG = '"e2e-range-etag"';
|
||||
export const INTERRUPTED_RANGE_SERVER_ETAG = '"e2e-reset-etag"';
|
||||
const downloadPlayCaptureKey = '__iptvnatorE2eDownloadPlayPaths';
|
||||
|
||||
export function getStalkerSeriesDownloadTarget(
|
||||
@@ -172,6 +177,63 @@ export async function createThrottledRangeServer(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the first full response after writing a valid prefix, then serves the
|
||||
* remainder to a Range retry. This matches a provider/proxy connection drop
|
||||
* without manufacturing a clean EOF.
|
||||
*/
|
||||
export async function createInterruptedRangeServer(): Promise<InterruptedRangeServer> {
|
||||
const payload = Buffer.alloc(64 * 1024, 9);
|
||||
const interruptedBytes = 16 * 1024;
|
||||
const requests: RangeServerRequest[] = [];
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const range = req.headers.range;
|
||||
const ifRange = req.headers['if-range'];
|
||||
requests.push({
|
||||
ifRange: typeof ifRange === 'string' ? ifRange : undefined,
|
||||
range: typeof range === 'string' ? range : undefined,
|
||||
});
|
||||
|
||||
const offset = range
|
||||
? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN)
|
||||
: 0;
|
||||
if (range && Number.isFinite(offset)) {
|
||||
res.writeHead(206, {
|
||||
'Content-Length': payload.length - offset,
|
||||
'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`,
|
||||
'Content-Type': 'video/mp4',
|
||||
ETag: INTERRUPTED_RANGE_SERVER_ETAG,
|
||||
});
|
||||
res.end(payload.subarray(offset));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Length': payload.length,
|
||||
'Content-Type': 'video/mp4',
|
||||
ETag: INTERRUPTED_RANGE_SERVER_ETAG,
|
||||
});
|
||||
res.write(payload.subarray(0, interruptedBytes), () => {
|
||||
setTimeout(() => res.socket?.destroy(), 20);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
interruptedBytes,
|
||||
payload,
|
||||
requests,
|
||||
url: `http://127.0.0.1:${port}/media/e2e-reset-movie.mp4`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a chunked response cleanly while Content-Range advertises a larger
|
||||
* representation. The runtime therefore retains the valid .part for a Range
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addStalkerPortal,
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
defaultStalkerMacAddress,
|
||||
defaultStalkerPortalName,
|
||||
defaultXtreamPortalName,
|
||||
expect,
|
||||
@@ -67,6 +68,61 @@ test.describe('Electron Provider Smoke Tests', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@stalker @electron delivers cmd to the portal decoded exactly once with query injection blocked', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
// Stored cmd with a pre-encoded token (%3A), a literal '+', and a
|
||||
// query-injection attempt (&injected=1#frag).
|
||||
const storedCmd =
|
||||
'ffrt3 http://example.com/ch/123?token=a%3Ab+c&injected=1#frag';
|
||||
|
||||
const response = await app.mainWindow.evaluate(
|
||||
async ({ url, macAddress, cmd }) =>
|
||||
window.electron.stalkerRequest({
|
||||
url,
|
||||
macAddress,
|
||||
params: { action: 'create_link', type: 'itv', cmd },
|
||||
}),
|
||||
{
|
||||
url: `${stalkerMockServer}/portal.php`,
|
||||
macAddress: defaultStalkerMacAddress,
|
||||
cmd: storedCmd,
|
||||
}
|
||||
);
|
||||
|
||||
const js = (
|
||||
response as {
|
||||
js: { cmd_received: string; query_keys_received: string[] };
|
||||
}
|
||||
).js;
|
||||
|
||||
// The portal must see the stored cmd decoded exactly once —
|
||||
// %3A → ':', '+' → space — the same view it gets from a real STB.
|
||||
// The old encodeURIComponent transport double-encoded '%' and
|
||||
// delivered the %3A/+ sequences still encoded.
|
||||
expect(js.cmd_received).toBe(
|
||||
'ffrt3 http://example.com/ch/123?token=a:b c&injected=1#frag'
|
||||
);
|
||||
|
||||
// The '&'/'#' inside cmd stayed inside the cmd value instead of
|
||||
// restructuring the portal query.
|
||||
expect(js.query_keys_received).toEqual([
|
||||
'JsHttpRequest',
|
||||
'action',
|
||||
'cmd',
|
||||
'type',
|
||||
]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream @electron shows refresh overlay immediately from the dashboard Xtream source menu', async ({
|
||||
dataDir,
|
||||
request,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
addStalkerPortal,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
resetMockServers,
|
||||
stalkerMockServer,
|
||||
test,
|
||||
waitForStalkerCatalog,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
/**
|
||||
* End-to-end proof that a BUILT-IN player's media requests carry the portal
|
||||
* credentials (mac cookie + Bearer token) — the root of the long-running
|
||||
* "only VLC works" cluster (#849, #910, #732): the web players used to
|
||||
* receive only User-Agent/Referer/Origin, so any stream gated on the portal
|
||||
* session could never play inline.
|
||||
*
|
||||
* The mock's `gated-stream` scenario makes `create_link` return this
|
||||
* server's own `/stream/gated/video.mp4`, which answers 403 unless the
|
||||
* request presents the mac cookie AND the MAC's current access token. A unit
|
||||
* test cannot show that a header reached the video element; playback
|
||||
* advancing past that gate can only happen when the scoped Electron header
|
||||
* override attached the credentials to the actual media request.
|
||||
*/
|
||||
|
||||
const GATED_MAC = '00:1A:79:00:00:09';
|
||||
const GATED_STREAM_URL = `${stalkerMockServer}/stream/gated/video.mp4`;
|
||||
// The full-portal URL shape: the app handshakes and holds a Bearer token,
|
||||
// which is exactly what the gated stream endpoint demands.
|
||||
const FULL_PORTAL_URL = `${stalkerMockServer}/stalker_portal/server/load.php`;
|
||||
|
||||
test('@electron @stalker built-in player plays an auth-gated portal stream', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
|
||||
// First prove the gate is real: a credential-less request is refused, so
|
||||
// a green playback assertion below cannot be a permissive-mock artifact.
|
||||
const bareResponse = await request.get(GATED_STREAM_URL);
|
||||
expect(bareResponse.status()).toBe(403);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
macAddress: GATED_MAC,
|
||||
portalUrl: FULL_PORTAL_URL,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
// The portal lands on Movies; live playback lives in the ITV layout.
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: /live|itv/i })
|
||||
.click();
|
||||
await app.mainWindow.waitForURL(/stalker.*itv/);
|
||||
|
||||
// The ITV view renders channels only after a category is selected;
|
||||
// index 0 is the "All channels" pseudo-category.
|
||||
const categories = app.mainWindow.locator('.category-item');
|
||||
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
|
||||
await categories.first().click();
|
||||
|
||||
const channels = app.mainWindow.locator(
|
||||
'[data-test-id="channel-item"]'
|
||||
);
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
await channels.first().click();
|
||||
|
||||
const video = app.mainWindow
|
||||
.locator('app-web-player-view video')
|
||||
.first();
|
||||
await expect(video).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Advancing playback past the 403 gate is only possible when the
|
||||
// media requests carried the portal cookie and Authorization header.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
video.evaluate(
|
||||
(element: HTMLVideoElement) => element.currentTime
|
||||
),
|
||||
{ timeout: 20_000 }
|
||||
)
|
||||
.toBeGreaterThan(0.5);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('playback-diagnostic-banner')
|
||||
).toBeHidden();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@electron @stalker built-in audio player plays an auth-gated radio stream', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
|
||||
// The radio branch renders the dedicated audio player instead of
|
||||
// WebPlayerViewComponent, so it exercises the Stalker live layout's own
|
||||
// header wiring — a gap the ITV test above cannot catch.
|
||||
const bareResponse = await request.get(
|
||||
`${stalkerMockServer}/stream/gated/audio.mp4`
|
||||
);
|
||||
expect(bareResponse.status()).toBe(403);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
macAddress: GATED_MAC,
|
||||
portalUrl: FULL_PORTAL_URL,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
await app.mainWindow.getByRole('link', { name: /radio/i }).click();
|
||||
await app.mainWindow.waitForURL(/stalker.*radio/);
|
||||
|
||||
const categories = app.mainWindow.locator('.category-item');
|
||||
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
|
||||
await categories.first().click();
|
||||
|
||||
const channels = app.mainWindow.locator(
|
||||
'[data-test-id="channel-item"]'
|
||||
);
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
await channels.first().click();
|
||||
|
||||
// The bare <audio> element renders zero-size (its UI is custom), so
|
||||
// assert attachment rather than visibility.
|
||||
const audio = app.mainWindow.locator('app-audio-player audio').first();
|
||||
await expect(audio).toBeAttached({ timeout: 15_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
audio.evaluate(
|
||||
(element: HTMLAudioElement) => element.currentTime
|
||||
),
|
||||
{ timeout: 20_000 }
|
||||
)
|
||||
.toBeGreaterThan(0.5);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user