diff --git a/.changes/dashboard-source-expiry-badge.md b/.changes/dashboard-source-expiry-badge.md new file mode 100644 index 000000000..7ea44e806 --- /dev/null +++ b/.changes/dashboard-source-expiry-badge.md @@ -0,0 +1,10 @@ +--- +type: feature +area: dashboard +--- + +Dashboard source cards now warn when a portal subscription is about to lapse: +an amber "Expires in N d" chip appears within a week of the expiry date, and +an "Expired" chip once it has passed. Xtream expirations come from the same +cached status check the playlist switcher uses; Stalker portals reuse the +account info saved at import. diff --git a/.changes/downloads-vod-reliability.md b/.changes/downloads-vod-reliability.md new file mode 100644 index 000000000..899f97e5a --- /dev/null +++ b/.changes/downloads-vod-reliability.md @@ -0,0 +1,7 @@ +--- +type: fix +area: downloads +issues: [897, 1289] +--- + +Xtream movie and episode downloads now keep their provider-compatible identity from the first request through legacy retries after source removal. Recoverable connection drops retain validated partials and show a credential-safe code; Retry resumes only with ETag or Last-Modified, otherwise it safely restarts. diff --git a/.changes/embedded-mpv-dock-menus.md b/.changes/embedded-mpv-dock-menus.md new file mode 100644 index 000000000..4245137af --- /dev/null +++ b/.changes/embedded-mpv-dock-menus.md @@ -0,0 +1,9 @@ +--- +type: fix +area: embedded-mpv +issues: [1139] +--- + +Opening the volume, audio, subtitle, speed or aspect menu in the Embedded MPV +player no longer makes the video jump or leave a black bar above the controls: +the menus now unfold inside the control bar instead of floating over the video. diff --git a/.changes/embedded-mpv-scaled-displays.md b/.changes/embedded-mpv-scaled-displays.md new file mode 100644 index 000000000..bb8f1b535 --- /dev/null +++ b/.changes/embedded-mpv-scaled-displays.md @@ -0,0 +1,11 @@ +--- +type: fix +area: embedded-mpv +issues: [1139, 1145] +--- + +On Windows and Linux displays scaled above 100%, the Embedded MPV video was +drawn toward the top-left corner at a fraction of its size, in windowed and +fullscreen mode alike. The video now fills the player area correctly at any +display scale and page zoom, with no need for a high-DPI compatibility +workaround. diff --git a/.changes/playback-stalker-stream-credentials.md b/.changes/playback-stalker-stream-credentials.md new file mode 100644 index 000000000..dc4bfde8e --- /dev/null +++ b/.changes/playback-stalker-stream-credentials.md @@ -0,0 +1,12 @@ +--- +type: fix +area: playback +issues: [849, 910, 732] +--- + +Stalker streams that require the portal session now play in the built-in +players (HTML5, Video.js, ArtPlayer), not only in VLC/MPV: the player's +requests carry the portal cookie and token, scoped to that stream and +dropped when the player closes or the channel changes. VOD, series and +radio get the same headers live TV had — also from Favorites and Recently +Viewed. diff --git a/.changes/pwa-stalker-transport-parity.md b/.changes/pwa-stalker-transport-parity.md new file mode 100644 index 000000000..db031f76b --- /dev/null +++ b/.changes/pwa-stalker-transport-parity.md @@ -0,0 +1,11 @@ +--- +type: fix +area: pwa +--- + +The self-hosted web app now talks to Stalker portals exactly like the desktop +app: MAG User-Agent, full STB cookie, serial-number header, and the +`JsHttpRequest` marker every real client sends — so portals that worked only in +the desktop app now work in the PWA too. Portal credentials (MAC and session +token) no longer travel in the portal request URL, keeping them out of server +logs. diff --git a/.changes/stalker-account-info-dialog.md b/.changes/stalker-account-info-dialog.md new file mode 100644 index 000000000..17fdbffb4 --- /dev/null +++ b/.changes/stalker-account-info-dialog.md @@ -0,0 +1,10 @@ +--- +type: feature +area: stalker +--- + +Stalker portals now have an Account info dialog — subscription status, +tariff plan, expiry date with a days-left counter, login, and portal +details. Open it from the header playlist menu, the dashboard source card, +or the command palette, same as for Xtream. If the portal is unreachable, +the data saved when the portal was added is shown instead. diff --git a/.changes/stalker-cmd-encoding.md b/.changes/stalker-cmd-encoding.md new file mode 100644 index 000000000..d265b0b0f --- /dev/null +++ b/.changes/stalker-cmd-encoding.md @@ -0,0 +1,10 @@ +--- +type: fix +area: stalker +--- + +Stalker portals now receive channel commands exactly as a real set-top box +sends them: already-encoded parts of a channel's `cmd` are no longer +double-encoded, so strict portals and reseller panels that compare the command +literally work again. Playing Stalker channels from Favorites or global +collections now also handles portals that answer with relative stream paths. diff --git a/.changes/ui-mobile-layout.md b/.changes/ui-mobile-layout.md new file mode 100644 index 000000000..a6ae38203 --- /dev/null +++ b/.changes/ui-mobile-layout.md @@ -0,0 +1,10 @@ +--- +type: fix +area: ui +issues: [1100] +--- + +IPTVnator is usable on a phone again. The navigation bar no longer sits off +screen, category and channel lists take the full width instead of squeezing +the content into a sliver, the video keeps a usable share of the screen, and +the M3U channel list can be reopened after hiding it. diff --git a/.changes/ui-phone-context-drawer.md b/.changes/ui-phone-context-drawer.md new file mode 100644 index 000000000..24fb14fac --- /dev/null +++ b/.changes/ui-phone-context-drawer.md @@ -0,0 +1,10 @@ +--- +type: feature +area: ui +issues: [1100] +--- + +On phone-sized screens the categories and filters panel no longer sits stacked +above the content — it is now a slide-in drawer, opened from a new button in +the header and closed by picking an entry, tapping outside it, or pressing +Escape. The content gets the whole screen while browsing. diff --git a/.changes/workspace-playlist-info-menu-item.md b/.changes/workspace-playlist-info-menu-item.md new file mode 100644 index 000000000..2a67ed63d --- /dev/null +++ b/.changes/workspace-playlist-info-menu-item.md @@ -0,0 +1,8 @@ +--- +type: fix +area: workspace +--- + +The playlist dropdown in the header shows a "Playlist info" entry for the +active playlist again, next to "Account info" and "Add playlist" — it had +disappeared when playlist actions moved into the per-row menu. diff --git a/.changes/workspace-sync-overlay-local-phase.md b/.changes/workspace-sync-overlay-local-phase.md new file mode 100644 index 000000000..477c56a2d --- /dev/null +++ b/.changes/workspace-sync-overlay-local-phase.md @@ -0,0 +1,9 @@ +--- +type: fix +area: workspace +--- + +The playlist sync overlay no longer shows a bare "Syncing playlist" card when +an Xtream playlist is loaded from the local library. Reading the saved catalog +from this device now reports its own phase, so the overlay always shows a +source badge and explains what is happening — remote fetch or local read. diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 000000000..3961241ca --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,20 @@ +name: 'IPTVnator CodeQL config' + +# The mock servers are development/E2E fixtures. They bind to loopback by +# default (HOST=0.0.0.0 is an explicit opt-in for pointing a phone/STB at +# them), serve fabricated data, ship in no released artifact, and deliberately +# imitate the quirks of the upstream IPTV protocols — including reading a +# session token from a GET query string, which is what the real Stalker +# backend proxy does and therefore what the app must be tested against. +# +# CodeQL's web-service hygiene rules (missing rate limiting, sensitive data in +# GET requests) assume an internet-facing service and produce only false +# positives here; a rate limiter on a fixture that the E2E suite hammers would +# actively break the tests. paths-ignore is all-or-nothing per path — CodeQL +# has no per-path rule filter — so this deliberately trades away injection/ +# path-traversal coverage for the two fixture apps, which parse no input +# beyond the local test driver. Everything the app itself ships keeps full +# coverage. +paths-ignore: + - apps/stalker-mock-server + - apps/xtream-mock-server diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fdccfe138..ccbfc3f11 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,8 @@ # Every Dependabot PR triggers the full pipeline (~15 jobs), so version -# updates are batched: weekly cadence, minor+patch bumps grouped into one PR -# per ecosystem, majors as individual PRs so CI gates them one by one. -# Security updates are separate and are not limited by this schedule. +# updates are batched weekly. Nx minor+patch updates use a dedicated group so +# official packages move together; other minor+patch updates are grouped per +# ecosystem, and majors remain individual. Security updates are separate and +# are not limited by this schedule; CI enforces complete Nx lockstep. version: 2 updates: - package-ecosystem: npm @@ -12,7 +13,24 @@ updates: time: '06:00' open-pull-requests-limit: 5 groups: + nx-version-updates: + applies-to: version-updates + patterns: + - nx + - '@nx/*' + update-types: + - minor + - patch + nx-security-updates: + applies-to: security-updates + patterns: + - nx + - '@nx/*' npm-minor-patch: + applies-to: version-updates + exclude-patterns: + - nx + - '@nx/*' update-types: - minor - patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af603b71a..5b72d2cba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Validate Nx dependency version policy + run: pnpm run deps:nx:validate + - name: Typecheck web and Electron entry points run: pnpm run typecheck:ci diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7f87287f3..003b8b879 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -47,6 +47,9 @@ jobs: uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} + # Excludes the localhost dev/E2E mock servers from analysis; see the + # config file for why. + config-file: ./.github/codeql/codeql-config.yml # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. diff --git a/AGENTS.md b/AGENTS.md index 0829e2403..32f1cb395 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ This file provides guidance to coding agents working in this repository. - Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. +- Keep `nx` and every official `@nx/*` package on the same exact version; run + `pnpm run deps:nx:validate` after dependency updates. +- Update Nx with `pnpm nx migrate nx@ --skipInstall`, regenerate the + lockfile, run generated migrations when present, and validate before opening + a PR. Major updates are always manual. Replace incomplete Dependabot security + PRs with a coordinated update instead of editing the bot branch. - ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 — a long spec signals coverage, not the design debt the production limit catches. Blank lines and comments are not counted, so a docblock never forces a split. Limits live in `tools/eslint/max-lines-config.mjs`, imported by both `eslint.config.mjs` and the generator so the rule and the baseline cannot drift. Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs` (it runs ESLint's own rule rather than counting lines itself). Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused. - Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find -name '*.ts' | wc -l`. - Repository-specific skills live under `.codex/skills/`. diff --git a/CLAUDE.md b/CLAUDE.md index 4ec301496..76f860b86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,12 @@ pnpm nx show projects - Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. +- Keep `nx` and every official `@nx/*` package on the same exact version; run + `pnpm run deps:nx:validate` after dependency updates. +- Update Nx with `pnpm nx migrate nx@ --skipInstall`, regenerate the + lockfile, run generated migrations when present, and validate before opening + a PR. Major updates are always manual. Replace incomplete Dependabot security + PRs with a coordinated update instead of editing the bot branch. - Repository-specific skills live under `.codex/skills/`. - Frontmatter descriptions are trigger-only and begin with `Use when`; keep each skill at or below 500 words. @@ -911,6 +917,19 @@ engine` (restart required) or **Download Manager**: +- Fresh Xtream movie and series-episode downloads propagate the playlist's + User-Agent, Referer, and Origin, defaulting User-Agent to the same + provider-compatible `XTREAM_CLIENT_USER_AGENT` used by API requests and + stream probes. Retry, resume, and missing-file + recovery also add the fallback to legacy Xtream rows that have no stored + User-Agent. Because download rows survive source deletion, a headerless + legacy row whose playlist is already absent receives the same IPTV-player + fallback; a known Stalker row remains unchanged. Allowlisted connection + resets after bytes reach disk retain the partial and show a credential-safe + `DOWNLOAD_NETWORK_INTERRUPTED` code only when the response supplied a strong + ETag or Last-Modified validator. Retry then continues with Range/If-Range; + without a validator it starts from byte zero and overwrites the unverified + partial instead of risking mixed-representation corruption. - The desktop-only manager shares one global download store across the global, Xtream-scoped, and Stalker-scoped routes. Completed movie and grouped-series cards use the global Small/Medium/Large cover-grid tokens; missing completed @@ -1020,6 +1039,13 @@ engine` (restart required) or - TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About - See `docs/architecture/tmdb-metadata-enrichment.md` +**Portal Account Info**: + +- Both portal types expose an account-info dialog through the same entry points: header playlist switcher (bottom section for the active playlist + per-row ⋮ menu), dashboard source card ⋮ menu, and the command palette. Gates use the shared predicates in `libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts`; `WorkspaceShellHeaderService.openAccountInfoFor()` picks the dialog by playlist type. +- Xtream: `AccountInfoComponent` (`libs/portal/xtream/feature/src/lib/account-info/`), queries `get_account_info` live. +- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes (full portals: handshake+`get_profile`; `portal.php`: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields). Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog"). +- Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once expired); account details remain behind ⋮ → Account info. `DashboardSourceExpiryService` (`libs/workspace/dashboard/data-access/`) gathers the facts: Xtream from `PortalStatusService.checkPortalStatusDetails()` (the switcher's cached status check, now carrying `exp_date`), Stalker from the persisted `stalkerAccountInfo` snapshot — it lives in the playlist payload, not on meta rows, so each Stalker source costs one memoized full-playlist read. + **Favorites and Recently Viewed**: - Per-playlist favorites and global favorites diff --git a/apps/electron-backend-e2e/src/download-reliability.e2e.ts b/apps/electron-backend-e2e/src/download-reliability.e2e.ts new file mode 100644 index 000000000..13b69fe11 --- /dev/null +++ b/apps/electron-backend-e2e/src/download-reliability.e2e.ts @@ -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 { + await page.getByRole('button', { name: 'Open downloads' }).click(); + await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); +} + +async function getPlaylistId(page: Page, title: string): Promise { + 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>; + }, 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(); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/downloads.e2e-support.ts b/apps/electron-backend-e2e/src/downloads.e2e-support.ts index fc7997f78..2ec21c4ab 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e-support.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e-support.ts @@ -31,6 +31,10 @@ interface ThrottledRangeServer { url: string; } +interface InterruptedRangeServer extends ThrottledRangeServer { + interruptedBytes: number; +} + interface TruncatedDownloadServer { close: () => Promise; 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 { + 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((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + + return { + close: () => + new Promise((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 diff --git a/apps/electron-backend-e2e/src/providers.e2e.ts b/apps/electron-backend-e2e/src/providers.e2e.ts index 0ff51b833..2fb590abc 100644 --- a/apps/electron-backend-e2e/src/providers.e2e.ts +++ b/apps/electron-backend-e2e/src/providers.e2e.ts @@ -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, diff --git a/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts b/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts new file mode 100644 index 000000000..c8e7e8f1d --- /dev/null +++ b/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts @@ -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