mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -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,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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+21
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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@<target> --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 -- <why> */`; 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/<project>/**/*.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 <project> -name '*.ts' | wc -l`.
|
||||
- Repository-specific skills live under `.codex/skills/`.
|
||||
|
||||
@@ -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@<target> --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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -140,7 +140,33 @@ describe('main preload DB IPC contract', () => {
|
||||
'set-user-agent',
|
||||
'ChannelAgent/1.0',
|
||||
'https://portal.example/referrer',
|
||||
'https://stream.example/live.m3u8'
|
||||
'https://stream.example/live.m3u8',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards scoped stream credentials alongside the header override', async () => {
|
||||
const api = getExposedApi();
|
||||
|
||||
await api.setUserAgent(
|
||||
'ChannelAgent/1.0',
|
||||
'https://portal.example/referrer',
|
||||
'https://stream.example/live.m3u8',
|
||||
{
|
||||
authorization: 'Bearer TOKEN',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01',
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
|
||||
'set-user-agent',
|
||||
'ChannelAgent/1.0',
|
||||
'https://portal.example/referrer',
|
||||
'https://stream.example/live.m3u8',
|
||||
{
|
||||
authorization: 'Bearer TOKEN',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -450,8 +450,19 @@ const electronApi: ElectronBridgeApi = {
|
||||
setUserAgent: (
|
||||
userAgent?: string | null,
|
||||
referer?: string | null,
|
||||
scopeUrl?: string | null
|
||||
) => ipcRenderer.invoke('set-user-agent', userAgent, referer, scopeUrl),
|
||||
scopeUrl?: string | null,
|
||||
credentials?: {
|
||||
authorization?: string | null;
|
||||
cookie?: string | null;
|
||||
} | null
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'set-user-agent',
|
||||
userAgent,
|
||||
referer,
|
||||
scopeUrl,
|
||||
credentials
|
||||
),
|
||||
openInMpv: (
|
||||
url: string,
|
||||
title: string,
|
||||
|
||||
@@ -14,12 +14,16 @@ import type {
|
||||
DownloadTask,
|
||||
TransferProgress,
|
||||
} from './download-task';
|
||||
import { describeError, TruncatedTransferError } from './download-transfer';
|
||||
import {
|
||||
describeError,
|
||||
InterruptedTransferError,
|
||||
TruncatedTransferError,
|
||||
} from './download-transfer';
|
||||
|
||||
/**
|
||||
* Persistence for a failed startDownload() attempt, after its cancel/pause
|
||||
* checkpoints have been ruled out. Chooses between: retaining a truncated
|
||||
* transfer for a Range retry, committing an already-finalized file,
|
||||
* checkpoints have been ruled out. Chooses between: retaining a recoverable
|
||||
* partial for a Range retry, committing an already-finalized file,
|
||||
* retaining a completed partial, or the generic delete-partial failure.
|
||||
*/
|
||||
export async function handleDownloadFailure(
|
||||
@@ -28,8 +32,12 @@ export async function handleDownloadFailure(
|
||||
reservation: ReservedPartialDownloadFile | undefined,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
if (error instanceof TruncatedTransferError && reservation) {
|
||||
// The short response is retained so a retry can continue the
|
||||
if (
|
||||
(error instanceof TruncatedTransferError ||
|
||||
error instanceof InterruptedTransferError) &&
|
||||
reservation
|
||||
) {
|
||||
// The recoverable partial is retained so a retry can continue the
|
||||
// transfer via Range instead of starting over.
|
||||
await persistCompletedPartialFailure(
|
||||
db,
|
||||
@@ -338,7 +346,9 @@ function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/** @returns false when a .part exists but could not be deleted. */
|
||||
export function removePartialFile(filePath: string | null | undefined): boolean {
|
||||
export function removePartialFile(
|
||||
filePath: string | null | undefined
|
||||
): boolean {
|
||||
try {
|
||||
removePartialDownloadFile(filePath);
|
||||
return true;
|
||||
|
||||
@@ -3,6 +3,7 @@ interface SetupOptions {
|
||||
cleanupError?: Error;
|
||||
claim?: { changes: number };
|
||||
fileReappeared?: boolean;
|
||||
playlistType?: 'xtream' | 'stalker';
|
||||
row?: Record<string, unknown> | null;
|
||||
urlError?: Error;
|
||||
}
|
||||
@@ -25,13 +26,18 @@ async function setup(options: SetupOptions = {}) {
|
||||
url: 'https://example.test/movie.mp4',
|
||||
...options.row,
|
||||
};
|
||||
const limit = jest.fn().mockResolvedValue(row ? [row] : []);
|
||||
const downloadLimit = jest.fn().mockResolvedValue(row ? [row] : []);
|
||||
const playlistLimit = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ type: options.playlistType ?? 'stalker' }]);
|
||||
const where = jest.fn().mockResolvedValue(options.claim ?? { changes: 1 });
|
||||
const set = jest.fn(() => ({ where }));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
select: jest.fn((selection?: unknown) => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
where: jest.fn(() => ({
|
||||
limit: selection ? playlistLimit : downloadLimit,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({ set })),
|
||||
@@ -118,6 +124,27 @@ describe('redownload missing completed file', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the Xtream fallback User-Agent to a legacy row', async () => {
|
||||
const harness = await setup({
|
||||
playlistType: 'xtream',
|
||||
row: {
|
||||
playlistId: 'playlist-1',
|
||||
requestHeaders: null,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(harness.redownloadMissingRequest(42)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(harness.enqueueDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers a file that reappeared without updating or enqueueing', async () => {
|
||||
const harness = await setup({ fileReappeared: true });
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as schema from '../../database/schema';
|
||||
import { assertRemoteUrlAllowed } from '../url-safety';
|
||||
import { isAvailableDownloadFile } from './download-file-availability';
|
||||
import { removePartialDownloadFile } from './download-file-path';
|
||||
import { parseStoredHeaders } from './download-requests';
|
||||
import { resolveStoredDownloadHeaders } from './download-request-headers';
|
||||
import { enqueueDownload } from './download-runtime';
|
||||
|
||||
export interface RedownloadMissingResult {
|
||||
@@ -61,6 +61,7 @@ export async function redownloadMissingRequest(
|
||||
}
|
||||
|
||||
await assertRemoteUrlAllowed(item.url, { allowPrivateNetworks: true });
|
||||
const headers = await resolveStoredDownloadHeaders(db, item);
|
||||
|
||||
try {
|
||||
removePartialDownloadFile(item.filePath);
|
||||
@@ -102,7 +103,7 @@ export async function redownloadMissingRequest(
|
||||
directory: dirname(item.filePath),
|
||||
fileName: basename(item.filePath),
|
||||
filePath: item.filePath,
|
||||
headers: parseStoredHeaders(item.requestHeaders),
|
||||
headers,
|
||||
id: item.id,
|
||||
resumeValidator: null,
|
||||
totalBytes: null,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { DownloadsDatabase } from './download-task';
|
||||
|
||||
function createDatabase(playlistType?: 'xtream' | 'stalker') {
|
||||
const limit = jest
|
||||
.fn()
|
||||
.mockResolvedValue(playlistType ? [{ type: playlistType }] : []);
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({ limit })),
|
||||
})),
|
||||
})),
|
||||
} as unknown as DownloadsDatabase;
|
||||
return { db, select: db.select };
|
||||
}
|
||||
|
||||
describe('stored download request headers', () => {
|
||||
it('adds the provider-compatible fallback for a legacy Xtream row', async () => {
|
||||
const { db } = createDatabase('xtream');
|
||||
const { resolveStoredDownloadHeaders } =
|
||||
await import('./download-request-headers');
|
||||
|
||||
await expect(
|
||||
resolveStoredDownloadHeaders(db, {
|
||||
playlistId: 'playlist-1',
|
||||
requestHeaders: JSON.stringify({
|
||||
Authorization: 'not-allowed',
|
||||
Referer: 'https://example.test/',
|
||||
}),
|
||||
})
|
||||
).resolves.toEqual({
|
||||
Referer: 'https://example.test/',
|
||||
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add the Xtream fallback to a Stalker row', async () => {
|
||||
const { db } = createDatabase('stalker');
|
||||
const { resolveStoredDownloadHeaders } =
|
||||
await import('./download-request-headers');
|
||||
|
||||
await expect(
|
||||
resolveStoredDownloadHeaders(db, {
|
||||
playlistId: 'playlist-1',
|
||||
requestHeaders: null,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds the player fallback when a legacy row outlives its deleted source', async () => {
|
||||
const { db } = createDatabase();
|
||||
const { resolveStoredDownloadHeaders } =
|
||||
await import('./download-request-headers');
|
||||
|
||||
await expect(
|
||||
resolveStoredDownloadHeaders(db, {
|
||||
playlistId: 'deleted-playlist',
|
||||
requestHeaders: null,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an explicit stored User-Agent without querying the playlist', async () => {
|
||||
const { db, select } = createDatabase('xtream');
|
||||
const { resolveStoredDownloadHeaders } =
|
||||
await import('./download-request-headers');
|
||||
|
||||
await expect(
|
||||
resolveStoredDownloadHeaders(db, {
|
||||
playlistId: 'playlist-1',
|
||||
requestHeaders: JSON.stringify({
|
||||
'User-Agent': 'Custom/1.0',
|
||||
}),
|
||||
})
|
||||
).resolves.toEqual({ 'User-Agent': 'Custom/1.0' });
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { XTREAM_CLIENT_USER_AGENT } from '@iptvnator/shared/interfaces';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as schema from '../../database/schema';
|
||||
import type { DownloadsDatabase } from './download-task';
|
||||
|
||||
const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const;
|
||||
|
||||
export function parseStoredHeaders(
|
||||
value: string | null | undefined
|
||||
): Record<string, string> | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Re-apply the write-time allowlist so a tampered or imported
|
||||
// database row cannot smuggle arbitrary headers into requests.
|
||||
const entries = parsed as Record<string, unknown>;
|
||||
const headers = STORED_HEADER_ALLOWLIST.reduce<Record<string, string>>(
|
||||
(acc, key) => {
|
||||
const headerValue = entries[key];
|
||||
if (typeof headerValue === 'string') {
|
||||
acc[key] = headerValue;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
return Object.keys(headers).length > 0 ? headers : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveStoredDownloadHeaders(
|
||||
db: DownloadsDatabase,
|
||||
item: { playlistId: string; requestHeaders: string | null | undefined }
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
const headers = parseStoredHeaders(item.requestHeaders);
|
||||
if (headers?.['User-Agent']?.trim()) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const playlists = await db
|
||||
.select({ type: schema.playlists.type })
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, item.playlistId))
|
||||
.limit(1);
|
||||
const playlistType = playlists[0]?.type;
|
||||
if (playlistType !== undefined && playlistType !== 'xtream') {
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Download rows intentionally survive individual playlist deletion. Older
|
||||
// rows have no stored provider marker, so a missing source must use the
|
||||
// IPTV-player fallback as the only recoverable identity-compatible default.
|
||||
return {
|
||||
...headers,
|
||||
'User-Agent': XTREAM_CLIENT_USER_AGENT,
|
||||
};
|
||||
}
|
||||
@@ -562,6 +562,66 @@ describe('download request identity resolution', () => {
|
||||
});
|
||||
|
||||
describe('download requests resume', () => {
|
||||
it('adds the Xtream fallback User-Agent to a legacy paused row', async () => {
|
||||
jest.resetModules();
|
||||
const schema = await import('../../database/schema');
|
||||
const row = {
|
||||
filePath: '/downloads/movie.mp4',
|
||||
id: 42,
|
||||
playlistId: 'playlist-1',
|
||||
requestHeaders: null,
|
||||
resumeValidator: '"etag-9"',
|
||||
status: 'paused',
|
||||
title: 'Movie',
|
||||
totalBytes: 100,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
};
|
||||
const downloadLimit = jest.fn().mockResolvedValue([row]);
|
||||
const playlistLimit = jest.fn().mockResolvedValue([{ type: 'xtream' }]);
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn((table: unknown) => ({
|
||||
where: jest.fn(() => ({
|
||||
limit:
|
||||
table === schema.playlists
|
||||
? playlistLimit
|
||||
: downloadLimit,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
update: jest.fn(() => ({
|
||||
set: jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue({ changes: 1 }),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const enqueueDownload = jest.fn();
|
||||
const authorizer = {
|
||||
requireAuthorized: jest.fn(async (directory: string) => directory),
|
||||
} as unknown as DownloadDirectoryAuthorizer;
|
||||
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: jest.fn().mockResolvedValue(db),
|
||||
}));
|
||||
jest.doMock('../url-safety', () => ({
|
||||
assertRemoteUrlAllowed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({ enqueueDownload }));
|
||||
|
||||
const { resumeDownloadRequest } = await import('./download-requests');
|
||||
await expect(
|
||||
resumeDownloadRequest(42, '/unused', authorizer)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expect(enqueueDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'User-Agent': 'VLC/3.0.18 LibVLC/3.0.18',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('enqueues a paused download with stored headers and original target path', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { assertRemoteUrlAllowed } from '../url-safety';
|
||||
import { DownloadDirectoryAuthorizer } from './download-directory-authorization';
|
||||
import { removePartialDownloadFile } from './download-file-path';
|
||||
import { resolveExistingDownloadIdentity } from './download-request-identity';
|
||||
import { resolveStoredDownloadHeaders } from './download-request-headers';
|
||||
import {
|
||||
assertDownloadMetadataArtworkDiffersFromStream,
|
||||
assertDownloadMetadataMatchesContentType,
|
||||
@@ -84,40 +85,6 @@ function serializeHeaders(
|
||||
return headers ? JSON.stringify(headers) : null;
|
||||
}
|
||||
|
||||
const STORED_HEADER_ALLOWLIST = ['User-Agent', 'Origin', 'Referer'] as const;
|
||||
|
||||
export function parseStoredHeaders(
|
||||
value: string | null
|
||||
): Record<string, string> | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Re-apply the write-time allowlist so a tampered or imported
|
||||
// database row cannot smuggle arbitrary headers into requests.
|
||||
const entries = parsed as Record<string, unknown>;
|
||||
const headers = STORED_HEADER_ALLOWLIST.reduce<Record<string, string>>(
|
||||
(acc, key) => {
|
||||
const headerValue = entries[key];
|
||||
if (typeof headerValue === 'string') {
|
||||
acc[key] = headerValue;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
return Object.keys(headers).length > 0 ? headers : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDownloadRequest(
|
||||
data: StartDownloadRequest,
|
||||
authorizer: DownloadDirectoryAuthorizer
|
||||
@@ -324,6 +291,7 @@ export async function retryDownloadRequest(
|
||||
const fileName = retainedFilePath
|
||||
? basename(retainedFilePath)
|
||||
: createFileName(item.title, item.url);
|
||||
const headers = await resolveStoredDownloadHeaders(db, item);
|
||||
const queuedUpdate = retainedFilePath
|
||||
? {
|
||||
errorMessage: null,
|
||||
@@ -349,7 +317,7 @@ export async function retryDownloadRequest(
|
||||
directory,
|
||||
fileName,
|
||||
filePath: retainedFilePath,
|
||||
headers: parseStoredHeaders(item.requestHeaders),
|
||||
headers,
|
||||
id: item.id,
|
||||
resumeValidator: retainedFilePath ? item.resumeValidator : null,
|
||||
totalBytes: retainedFilePath ? item.totalBytes : null,
|
||||
@@ -392,6 +360,7 @@ export async function resumeDownloadRequest(
|
||||
const fileName = item.filePath
|
||||
? basename(item.filePath)
|
||||
: createFileName(item.title, item.url);
|
||||
const headers = await resolveStoredDownloadHeaders(db, item);
|
||||
|
||||
// Claim the row atomically: a concurrent resume for the same id loses
|
||||
// this conditional update and must not enqueue a second task.
|
||||
@@ -420,7 +389,7 @@ export async function resumeDownloadRequest(
|
||||
directory,
|
||||
fileName,
|
||||
filePath: item.filePath,
|
||||
headers: parseStoredHeaders(item.requestHeaders),
|
||||
headers,
|
||||
id: item.id,
|
||||
resumeValidator: item.resumeValidator,
|
||||
totalBytes: item.totalBytes,
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('destination collision handling', () => {
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
filePath: '/downloads/movie.mp4',
|
||||
resumeValidator: '"etag-1"',
|
||||
totalBytes: 100,
|
||||
});
|
||||
await waitForStatus(set, 'completed');
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ResumeHarness {
|
||||
|
||||
interface ResumeHarnessOptions {
|
||||
partialSize: number;
|
||||
partialSizeAfterTransferError?: number;
|
||||
response: {
|
||||
data: Readable;
|
||||
headers: Record<string, string>;
|
||||
@@ -82,7 +83,12 @@ async function setupResumeHarness(
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
getPartialDownloadPath: (filePath: string) => `${filePath}.part`,
|
||||
getPartialDownloadSize: jest.fn(() => options.partialSize),
|
||||
getPartialDownloadSize: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(options.partialSize)
|
||||
.mockReturnValue(
|
||||
options.partialSizeAfterTransferError ?? options.partialSize
|
||||
),
|
||||
removePartialDownloadFile,
|
||||
reserveAvailablePartialDownloadFile: jest.fn(
|
||||
(directory: string, filename: string) => ({
|
||||
@@ -145,6 +151,34 @@ describe('download resume validation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts without Range when a retained partial has no validator', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
partialSize: 50,
|
||||
response: {
|
||||
data: Readable.from([Buffer.from('full')]),
|
||||
headers: { 'content-length': '4', etag: '"etag-new"' },
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
totalBytes: 54,
|
||||
})
|
||||
);
|
||||
await waitForStatus(harness.set, 'completed');
|
||||
|
||||
const requestOptions =
|
||||
harness.requestWithValidatedRedirects.mock.calls[0][1];
|
||||
expect(requestOptions.headers).toEqual({});
|
||||
expect(harness.createWriteStream).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4.part',
|
||||
{ flags: 'w' }
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts from byte zero when a resume request is answered with 200', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
@@ -209,6 +243,7 @@ describe('download resume validation', () => {
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
resumeValidator: '"etag-1"',
|
||||
totalBytes: 54,
|
||||
})
|
||||
);
|
||||
@@ -245,6 +280,7 @@ describe('download resume validation', () => {
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
resumeValidator: '"etag-1"',
|
||||
totalBytes: 100,
|
||||
})
|
||||
);
|
||||
@@ -264,6 +300,191 @@ describe('download resume validation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('retains received bytes when the connection resets mid-transfer', async () => {
|
||||
const body = new PassThrough();
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 'enoent',
|
||||
partialSize: 0,
|
||||
partialSizeAfterTransferError: 20,
|
||||
response: {
|
||||
data: body,
|
||||
headers: {
|
||||
'content-length': '100',
|
||||
etag: '"etag-reset"',
|
||||
},
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
harness.runtime.enqueueDownload(createTask());
|
||||
while (
|
||||
harness.requestWithValidatedRedirects.mock.calls.length < 1
|
||||
) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
body.write(Buffer.alloc(20, 'r'));
|
||||
const resetError = new Error(
|
||||
'socket hang up'
|
||||
) as NodeJS.ErrnoException;
|
||||
resetError.code = 'ECONNRESET';
|
||||
body.destroy(resetError);
|
||||
await waitForStatus(harness.set, 'failed');
|
||||
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 20,
|
||||
errorMessage:
|
||||
'DOWNLOAD_NETWORK_INTERRUPTED (ECONNRESET): Retry to continue from the saved partial file',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'failed',
|
||||
totalBytes: 100,
|
||||
})
|
||||
);
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resumeValidator: '"etag-reset"',
|
||||
})
|
||||
);
|
||||
expect(harness.removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('retains an existing partial when a resumed response resets before another byte', async () => {
|
||||
const body = new PassThrough();
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 'enoent',
|
||||
partialSize: 40,
|
||||
partialSizeAfterTransferError: 40,
|
||||
response: {
|
||||
data: body,
|
||||
headers: { 'content-range': 'bytes 40-99/100' },
|
||||
status: 206,
|
||||
},
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
harness.runtime.enqueueDownload(
|
||||
createTask({
|
||||
filePath: '/downloads/movie.mp4',
|
||||
resumeValidator: '"etag-reset"',
|
||||
totalBytes: 100,
|
||||
})
|
||||
);
|
||||
while (
|
||||
harness.requestWithValidatedRedirects.mock.calls.length < 1
|
||||
) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
const resetError = new Error(
|
||||
'socket hang up'
|
||||
) as NodeJS.ErrnoException;
|
||||
resetError.code = 'ECONNRESET';
|
||||
body.destroy(resetError);
|
||||
await waitForStatus(harness.set, 'failed');
|
||||
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bytesDownloaded: 40,
|
||||
errorMessage:
|
||||
'DOWNLOAD_NETWORK_INTERRUPTED (ECONNRESET): Retry to continue from the saved partial file',
|
||||
filePath: '/downloads/movie.mp4',
|
||||
status: 'failed',
|
||||
totalBytes: 100,
|
||||
})
|
||||
);
|
||||
expect(harness.removePartialDownloadFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: 'EUNKNOWN',
|
||||
headers: { 'content-length': '100' },
|
||||
label: 'unknown stream error',
|
||||
partialSizeAfterTransferError: 20,
|
||||
},
|
||||
{
|
||||
code: 'ECONNRESET',
|
||||
headers: {},
|
||||
label: 'response without an advertised total',
|
||||
partialSizeAfterTransferError: 20,
|
||||
},
|
||||
{
|
||||
code: 'ECONNRESET',
|
||||
headers: { 'content-length': '100' },
|
||||
label: 'response without a representation validator',
|
||||
partialSizeAfterTransferError: 20,
|
||||
},
|
||||
{
|
||||
code: 'ECONNRESET',
|
||||
headers: { 'content-length': '100' },
|
||||
label: 'fresh zero-byte failure',
|
||||
partialSizeAfterTransferError: 0,
|
||||
},
|
||||
{
|
||||
code: 'ECONNRESET',
|
||||
headers: { 'content-length': '100' },
|
||||
label: 'partial larger than the advertised total',
|
||||
partialSizeAfterTransferError: 101,
|
||||
},
|
||||
])(
|
||||
'uses generic cleanup for $label',
|
||||
async ({ code, headers, partialSizeAfterTransferError }) => {
|
||||
const body = new PassThrough();
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 'enoent',
|
||||
partialSize: 0,
|
||||
partialSizeAfterTransferError,
|
||||
response: { data: body, headers, status: 200 },
|
||||
});
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
harness.runtime.enqueueDownload(createTask());
|
||||
while (
|
||||
harness.requestWithValidatedRedirects.mock.calls.length < 1
|
||||
) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
const streamError = new Error(
|
||||
'socket hang up'
|
||||
) as NodeJS.ErrnoException;
|
||||
streamError.code = code;
|
||||
body.destroy(streamError);
|
||||
await waitForStatus(harness.set, 'failed');
|
||||
|
||||
expect(harness.removePartialDownloadFile).toHaveBeenCalledWith(
|
||||
'/downloads/movie.mp4'
|
||||
);
|
||||
expect(harness.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
errorMessage: 'socket hang up',
|
||||
filePath: null,
|
||||
status: 'failed',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('captures a strong ETag from the first response for later resumes', async () => {
|
||||
const harness = await setupResumeHarness({
|
||||
finalSize: 4,
|
||||
|
||||
@@ -42,17 +42,16 @@ describe('download runtime pause and resume', () => {
|
||||
it('persists active pause without deleting the partial file', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
const removePartialDownloadFile = jest.fn();
|
||||
const stream = new PassThrough();
|
||||
const requestWithValidatedRedirects = jest.fn(
|
||||
async (
|
||||
_url: string,
|
||||
options: { signal?: AbortSignal }
|
||||
) => {
|
||||
async (_url: string, options: { signal?: AbortSignal }) => {
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
stream.destroy(new Error('aborted'));
|
||||
});
|
||||
@@ -119,10 +118,12 @@ describe('download runtime pause and resume', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses an HTTP Range header when a partial file already exists', async () => {
|
||||
it('uses an HTTP Range header when a validated partial file already exists', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
@@ -168,13 +169,17 @@ describe('download runtime pause and resume', () => {
|
||||
runtime.enqueueDownload({
|
||||
...createTask(),
|
||||
filePath: '/downloads/movie.mp4',
|
||||
resumeValidator: '"etag-1"',
|
||||
});
|
||||
await waitForCallCount(requestWithValidatedRedirects, 1);
|
||||
|
||||
expect(requestWithValidatedRedirects).toHaveBeenCalledWith(
|
||||
'https://example.test/movie.mp4',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Range: 'bytes=50-' }),
|
||||
headers: expect.objectContaining({
|
||||
'If-Range': '"etag-1"',
|
||||
Range: 'bytes=50-',
|
||||
}),
|
||||
}),
|
||||
{ allowPrivateNetworks: true }
|
||||
);
|
||||
@@ -184,7 +189,9 @@ describe('download runtime pause and resume', () => {
|
||||
it('deletes a queued resumed partial file when the queued task is canceled', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
update: jest.fn(() => ({ set })),
|
||||
};
|
||||
@@ -271,7 +278,9 @@ describe('download runtime pause and resume', () => {
|
||||
it('retains the partial path when canceling a queued task whose partial cannot be deleted', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = { update: jest.fn(() => ({ set })) };
|
||||
const removePartialDownloadFile = jest.fn(() => {
|
||||
throw new Error('EPERM: locked');
|
||||
@@ -338,7 +347,9 @@ describe('download runtime pause and resume', () => {
|
||||
it('retains the partial path when canceling a paused row whose partial cannot be deleted', async () => {
|
||||
jest.resetModules();
|
||||
|
||||
const set = jest.fn(() => ({ where: jest.fn().mockResolvedValue(undefined) }));
|
||||
const set = jest.fn(() => ({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const db = {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
|
||||
@@ -34,12 +34,39 @@ export class TruncatedTransferError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const RETAINABLE_NETWORK_ERROR_CODES = new Set([
|
||||
'ECONNABORTED',
|
||||
'ECONNRESET',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
'ERR_HTTP2_STREAM_CANCEL',
|
||||
'ERR_HTTP2_STREAM_ERROR',
|
||||
'ERR_STREAM_PREMATURE_CLOSE',
|
||||
]);
|
||||
|
||||
export class InterruptedTransferError extends Error {
|
||||
constructor(
|
||||
readonly progress: TransferProgress,
|
||||
networkCode: string
|
||||
) {
|
||||
super(
|
||||
`DOWNLOAD_NETWORK_INTERRUPTED (${networkCode}): Retry to continue from the saved partial file`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function transferToPartialFile(
|
||||
db: DownloadsDatabase,
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile
|
||||
): Promise<TransferProgress> {
|
||||
const resumeOffset = getResumeOffset(task, reservation);
|
||||
const retainedOffset = getResumeOffset(task, reservation);
|
||||
const resumeOffset = task.resumeValidator ? retainedOffset : 0;
|
||||
if (retainedOffset > 0 && resumeOffset === 0) {
|
||||
console.warn(
|
||||
`[Downloads] Restarting ${reservation.filename} from the beginning (saved partial has no ETag or Last-Modified validator)`
|
||||
);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...(task.headers ?? {}),
|
||||
@@ -130,6 +157,22 @@ export async function transferToPartialFile(
|
||||
|
||||
try {
|
||||
await pipeline(readable, output);
|
||||
} catch (error) {
|
||||
const interruptedProgress = getInterruptedTransferProgress(
|
||||
error,
|
||||
reservation,
|
||||
effectiveOffset,
|
||||
totalBytes,
|
||||
task.resumeValidator
|
||||
);
|
||||
if (interruptedProgress) {
|
||||
await persistProgress(db, task, interruptedProgress.progress);
|
||||
throw new InterruptedTransferError(
|
||||
interruptedProgress.progress,
|
||||
interruptedProgress.networkCode
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
abortController.signal.removeEventListener('abort', abortStream);
|
||||
}
|
||||
@@ -141,6 +184,40 @@ export async function transferToPartialFile(
|
||||
return { bytesDownloaded, totalBytes };
|
||||
}
|
||||
|
||||
function getInterruptedTransferProgress(
|
||||
error: unknown,
|
||||
reservation: ReservedPartialDownloadFile,
|
||||
initialBytes: number,
|
||||
totalBytes: number | null,
|
||||
resumeValidator: string | null | undefined
|
||||
): { networkCode: string; progress: TransferProgress } | null {
|
||||
const networkCode =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? String(error.code)
|
||||
: '';
|
||||
if (
|
||||
!RETAINABLE_NETWORK_ERROR_CODES.has(networkCode) ||
|
||||
totalBytes === null ||
|
||||
!resumeValidator
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytesDownloaded = getPartialDownloadSize(reservation.path);
|
||||
if (
|
||||
bytesDownloaded === 0 ||
|
||||
bytesDownloaded < initialBytes ||
|
||||
bytesDownloaded >= totalBytes
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
networkCode,
|
||||
progress: { bytesDownloaded, totalBytes },
|
||||
};
|
||||
}
|
||||
|
||||
function getResumeOffset(
|
||||
task: DownloadTask,
|
||||
reservation: ReservedPartialDownloadFile
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { configureRequestHeaderOverride } from '../services/request-header-overrides.service';
|
||||
import {
|
||||
configureRequestHeaderOverride,
|
||||
type StreamCredentialHeaders,
|
||||
} from '../services/request-header-overrides.service';
|
||||
|
||||
export default class SharedEvents {
|
||||
static bootstrapSharedEvents(): Electron.IpcMain {
|
||||
@@ -7,21 +10,46 @@ export default class SharedEvents {
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('set-user-agent', (_event, userAgent, referer, scopeUrl) => {
|
||||
setUserAgent(userAgent, referer, scopeUrl);
|
||||
return true;
|
||||
});
|
||||
function sanitizeCredentials(value: unknown): StreamCredentialHeaders | null {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { cookie, authorization } = value as Record<string, unknown>;
|
||||
return {
|
||||
authorization:
|
||||
typeof authorization === 'string' ? authorization : undefined,
|
||||
cookie: typeof cookie === 'string' ? cookie : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
ipcMain.handle(
|
||||
'set-user-agent',
|
||||
(_event, userAgent, referer, scopeUrl, credentials) => {
|
||||
setUserAgent(
|
||||
userAgent,
|
||||
referer,
|
||||
scopeUrl,
|
||||
sanitizeCredentials(credentials)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets scoped request headers for the currently selected stream.
|
||||
* @param userAgent user agent to use
|
||||
* @param referer referer to use
|
||||
* @param scopeUrl stream URL used to limit the override to the active origin
|
||||
* @param credentials portal Cookie/Authorization for auth-gated streams;
|
||||
* applied only to the exact origin of `scopeUrl` and only while the
|
||||
* scoped override is active
|
||||
*/
|
||||
export function setUserAgent(
|
||||
userAgent?: string | null,
|
||||
referer?: string | null,
|
||||
scopeUrl?: string | null
|
||||
scopeUrl?: string | null,
|
||||
credentials?: StreamCredentialHeaders | null
|
||||
): void {
|
||||
configureRequestHeaderOverride(userAgent, referer, scopeUrl);
|
||||
configureRequestHeaderOverride(userAgent, referer, scopeUrl, credentials);
|
||||
}
|
||||
@@ -8,11 +8,12 @@ import { ipcMain } from 'electron';
|
||||
import {
|
||||
PortalDebugEvent,
|
||||
STALKER_REQUEST,
|
||||
buildStalkerIdentityRequestContext,
|
||||
buildStalkerRequestUrl,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
|
||||
import { emitPortalDebugEvent } from './portal-debug.events';
|
||||
import { buildStalkerIdentityRequestContext } from './stalker-identity';
|
||||
import { assertRemoteUrlAllowed } from './url-safety';
|
||||
import { requestWithValidatedRedirects } from '../util/validated-axios';
|
||||
|
||||
@@ -58,42 +59,11 @@ ipcMain.handle(
|
||||
serialNumber,
|
||||
});
|
||||
|
||||
// Build URL with query parameters
|
||||
// Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent)
|
||||
// to preserve forward slashes, matching stalker-to-m3u implementation
|
||||
// SSRF/LFI guard: block non-http(s)/credentialed portal URLs.
|
||||
// Private/LAN targets remain allowed (users run local Stalker servers).
|
||||
await assertRemoteUrlAllowed(url, { allowPrivateNetworks: true });
|
||||
const urlObject = new URL(url);
|
||||
const queryParts: string[] = [];
|
||||
|
||||
Object.entries(requestParams).forEach(([key, value]) => {
|
||||
if (key === 'cmd') {
|
||||
// Encode cmd but preserve forward slashes so the path format
|
||||
// (e.g. /media/12345.mpg) the server expects still survives.
|
||||
// Encoding the remaining characters prevents a malicious portal
|
||||
// from injecting extra query parameters (&, =, #) into the URL.
|
||||
queryParts.push(
|
||||
`${key}=${encodeURIComponent(String(value)).replace(
|
||||
/%2F/gi,
|
||||
'/'
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
// Use encodeURIComponent for other params
|
||||
queryParts.push(
|
||||
`${key}=${encodeURIComponent(String(value))}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Always add JsHttpRequest parameter if not present (required by Stalker API)
|
||||
if (!requestParams['JsHttpRequest']) {
|
||||
queryParts.push('JsHttpRequest=1-xml');
|
||||
}
|
||||
|
||||
// Build final URL with manually constructed query string
|
||||
const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
|
||||
const fullUrl = buildStalkerRequestUrl(url, requestParams);
|
||||
|
||||
// Determine timeout based on action type
|
||||
// create_link requests can take longer as server generates stream URL
|
||||
|
||||
@@ -13,13 +13,10 @@
|
||||
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { ipcMain } from 'electron';
|
||||
import { XTREAM_CLIENT_USER_AGENT } from '@iptvnator/shared/interfaces';
|
||||
import { UnsafeUrlError } from './url-safety';
|
||||
import { requestWithValidatedRedirects } from '../util/validated-axios';
|
||||
|
||||
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
|
||||
// challenge generic browser-looking User-Agents while allowlisting known
|
||||
// IPTV player clients. A VLC-style User-Agent reliably passes those checks.
|
||||
const PROBE_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18';
|
||||
const PROBE_TIMEOUT_MS = 10000;
|
||||
|
||||
export interface StreamProbePayload {
|
||||
@@ -64,7 +61,7 @@ export async function runStreamProbe(
|
||||
// The playlist's own User-Agent wins when it has one: the default
|
||||
// below is a guess that merely gets past most WAFs, while that
|
||||
// one is what the server was configured to expect.
|
||||
'User-Agent': payload.userAgent?.trim() || PROBE_CLIENT_USER_AGENT,
|
||||
'User-Agent': payload.userAgent?.trim() || XTREAM_CLIENT_USER_AGENT,
|
||||
...(payload.referer?.trim()
|
||||
? { Referer: payload.referer.trim() }
|
||||
: {}),
|
||||
@@ -95,8 +92,7 @@ export async function runStreamProbe(
|
||||
);
|
||||
// A ranged GET opens a stream we never read — release it immediately.
|
||||
const responseBody = response.data as
|
||||
| { destroy?: () => void }
|
||||
| undefined;
|
||||
{ destroy?: () => void } | undefined;
|
||||
responseBody?.destroy?.();
|
||||
return {
|
||||
status: response.status,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ipcMain } from 'electron';
|
||||
import {
|
||||
PortalDebugEvent,
|
||||
XTREAM_CANCEL_SESSION,
|
||||
XTREAM_CLIENT_USER_AGENT,
|
||||
XTREAM_MAIN_PERFORMANCE_PHASE,
|
||||
normalizeXtreamServerUrl,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -20,11 +21,6 @@ import {
|
||||
} from './xtream-performance';
|
||||
import { cancelXtreamSessionRequests } from './xtream-session-cancellation';
|
||||
|
||||
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
|
||||
// challenge generic browser-looking User-Agents while allowlisting known
|
||||
// IPTV player clients. A VLC-style User-Agent reliably passes those checks.
|
||||
const XTREAM_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18';
|
||||
|
||||
export default class XtreamEvents {
|
||||
static bootstrapXtreamEvents(): Electron.IpcMain {
|
||||
return ipcMain;
|
||||
|
||||
@@ -339,3 +339,220 @@ describe('request header overrides', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Portal credentials (Cookie/Authorization) in the scoped override — the
|
||||
* mechanism that lets built-in players play auth-gated portal streams. The
|
||||
* security contract pinned here: credentials apply ONLY to the exact stream
|
||||
* origin, never ride on the broader UA/Referer scope, never enter the
|
||||
* unscoped (playlist-level) layer, and are dropped when the scoped override
|
||||
* is cleared or replaced.
|
||||
*/
|
||||
describe('request header override credentials', () => {
|
||||
const STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
|
||||
const SEGMENT_URL = 'http://portal.example:8080/live/segment-1.ts';
|
||||
const CREDENTIALS = {
|
||||
authorization: 'Bearer TOKEN123',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
mockOnBeforeSendHeaders.mockClear();
|
||||
});
|
||||
|
||||
it('attaches cookie and authorization to requests on the stream origin', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL);
|
||||
|
||||
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
|
||||
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
|
||||
expect(headers['User-Agent']).toBe('MAG250');
|
||||
expect(headers['Referer']).toBe('http://portal.example');
|
||||
});
|
||||
|
||||
it('does not attach credentials to the referer origin', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
// The UA/Referer scope includes the referer origin (port 80), but
|
||||
// the credentials belong to the stream origin (:8080) only.
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(
|
||||
listener,
|
||||
'http://portal.example/some/page'
|
||||
);
|
||||
|
||||
expect(headers['User-Agent']).toBe('MAG250');
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never attaches credentials to a third-party host', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(
|
||||
listener,
|
||||
'http://cdn.other-host.example/seg.ts',
|
||||
{ Accept: '*/*' }
|
||||
);
|
||||
|
||||
expect(headers).toEqual({ Accept: '*/*' });
|
||||
});
|
||||
|
||||
it('ignores credentials passed without a scope URL', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'PlaylistAgent/1.0',
|
||||
undefined,
|
||||
undefined,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL);
|
||||
|
||||
expect(headers['User-Agent']).toBe('PlaylistAgent/1.0');
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops credentials when the next stream replaces the scoped override', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
configureRequestHeaderOverride(
|
||||
'OtherAgent/1.0',
|
||||
'http://other.example',
|
||||
'http://other.example/stream.m3u8'
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL);
|
||||
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the credentialed override when playback ends', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
// The renderer's playback-end clear: empty values with a scope URL.
|
||||
configureRequestHeaderOverride(undefined, undefined, STREAM_URL);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL, {
|
||||
Accept: '*/*',
|
||||
});
|
||||
|
||||
expect(headers).toEqual({ Accept: '*/*' });
|
||||
});
|
||||
|
||||
it('keeps a credentials-only override active without UA or referer', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
undefined,
|
||||
undefined,
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL);
|
||||
|
||||
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
|
||||
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
|
||||
});
|
||||
|
||||
it('replaces existing credential headers case-insensitively', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
CREDENTIALS
|
||||
);
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL, {
|
||||
authorization: 'Bearer STALE',
|
||||
cookie: 'stale=1',
|
||||
});
|
||||
|
||||
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
|
||||
expect(headers['cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
|
||||
expect(headers['authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects credential values containing control characters', async () => {
|
||||
const { configureRequestHeaderOverride } = await import(
|
||||
'./request-header-overrides.service'
|
||||
);
|
||||
|
||||
configureRequestHeaderOverride('MAG250', undefined, STREAM_URL, {
|
||||
authorization: 'Bearer TOKEN\r\nX-Injected: 1',
|
||||
cookie: 'mac=00\r\nX-Injected: 1',
|
||||
});
|
||||
|
||||
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
|
||||
const headers = runHeaderListener(listener, SEGMENT_URL);
|
||||
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
expect(headers['X-Injected']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import { session } from 'electron';
|
||||
|
||||
export type StreamCredentialHeaders = {
|
||||
authorization?: string | null;
|
||||
cookie?: string | null;
|
||||
};
|
||||
|
||||
type HeaderOverride = {
|
||||
authorization?: string;
|
||||
cookie?: string;
|
||||
/**
|
||||
* Origin the credentials belong to — always the stream URL's own origin.
|
||||
* Cookie/Authorization are attached only on an exact match, never on the
|
||||
* broader `scopeOrigins` set that User-Agent/Referer/Origin use.
|
||||
*/
|
||||
credentialOrigin?: string;
|
||||
origin?: string;
|
||||
referer?: string;
|
||||
scopeOrigins?: Set<string>;
|
||||
@@ -29,8 +42,19 @@ let activeScopedHeaderOverride: HeaderOverride | null = null;
|
||||
let listenerRegistered = false;
|
||||
|
||||
function normalizeHeaderValue(value?: string | null): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
// A control character in a header value is never legitimate and could
|
||||
// otherwise smuggle extra headers into the raw request.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!trimmed || /[\u0000-\u001f\u007f]/.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getOrigin(value?: string | null): string | undefined {
|
||||
@@ -116,6 +140,8 @@ function handleBeforeSendHeaders(
|
||||
return;
|
||||
}
|
||||
|
||||
const requestOrigin = getOrigin(details.url);
|
||||
|
||||
for (const override of overrides) {
|
||||
if (override.userAgent) {
|
||||
setRequestHeader(requestHeaders, 'User-Agent', override.userAgent);
|
||||
@@ -128,6 +154,25 @@ function handleBeforeSendHeaders(
|
||||
if (override.origin) {
|
||||
setRequestHeader(requestHeaders, 'Origin', override.origin);
|
||||
}
|
||||
|
||||
// Portal credentials are attached only to requests going to the
|
||||
// stream's own origin — never to a referer-origin sibling and never
|
||||
// to third-party hosts a manifest may point at.
|
||||
const credentialsApply =
|
||||
Boolean(override.credentialOrigin) &&
|
||||
requestOrigin === override.credentialOrigin;
|
||||
|
||||
if (credentialsApply && override.cookie) {
|
||||
setRequestHeader(requestHeaders, 'Cookie', override.cookie);
|
||||
}
|
||||
|
||||
if (credentialsApply && override.authorization) {
|
||||
setRequestHeader(
|
||||
requestHeaders,
|
||||
'Authorization',
|
||||
override.authorization
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
callback({ requestHeaders });
|
||||
@@ -148,13 +193,33 @@ function ensureHeaderOverrideListener(): void {
|
||||
export function configureRequestHeaderOverride(
|
||||
userAgent?: string | null,
|
||||
referer?: string | null,
|
||||
scopeUrl?: string | null
|
||||
scopeUrl?: string | null,
|
||||
credentials?: StreamCredentialHeaders | null
|
||||
): void {
|
||||
const normalizedUserAgent = normalizeHeaderValue(userAgent);
|
||||
const normalizedReferer = normalizeHeaderValue(referer);
|
||||
const isScopedOverride = scopeUrl !== undefined && scopeUrl !== null;
|
||||
const scopeOrigin = getOrigin(scopeUrl);
|
||||
// Credentials are portal secrets: they require a scoped override whose
|
||||
// stream URL yields a concrete origin to pin them to. Anything else is
|
||||
// dropped rather than applied broadly (fail closed). They live only in
|
||||
// this in-memory override — never in the session cookie jar and never on
|
||||
// disk — so they cannot outlive the app process.
|
||||
const normalizedCookie =
|
||||
isScopedOverride && scopeOrigin
|
||||
? normalizeHeaderValue(credentials?.cookie)
|
||||
: undefined;
|
||||
const normalizedAuthorization =
|
||||
isScopedOverride && scopeOrigin
|
||||
? normalizeHeaderValue(credentials?.authorization)
|
||||
: undefined;
|
||||
|
||||
if (!normalizedUserAgent && !normalizedReferer) {
|
||||
if (
|
||||
!normalizedUserAgent &&
|
||||
!normalizedReferer &&
|
||||
!normalizedCookie &&
|
||||
!normalizedAuthorization
|
||||
) {
|
||||
if (isScopedOverride) {
|
||||
clearScopedRequestHeaderOverride();
|
||||
} else {
|
||||
@@ -164,7 +229,6 @@ export function configureRequestHeaderOverride(
|
||||
}
|
||||
|
||||
const refererOrigin = getOrigin(normalizedReferer);
|
||||
const scopeOrigin = getOrigin(scopeUrl);
|
||||
const override: HeaderOverride = {
|
||||
origin: refererOrigin,
|
||||
referer: normalizedReferer,
|
||||
@@ -177,6 +241,11 @@ export function configureRequestHeaderOverride(
|
||||
Boolean(origin)
|
||||
)
|
||||
);
|
||||
if (normalizedCookie || normalizedAuthorization) {
|
||||
override.credentialOrigin = scopeOrigin;
|
||||
override.cookie = normalizedCookie;
|
||||
override.authorization = normalizedAuthorization;
|
||||
}
|
||||
activeScopedHeaderOverride = override;
|
||||
} else {
|
||||
activeHeaderOverride = override;
|
||||
|
||||
@@ -52,4 +52,58 @@ describe('stalker playback context', () => {
|
||||
expect(headers).not.toHaveProperty('SN');
|
||||
expect(headers['Cookie']).not.toContain('__cfduid=');
|
||||
});
|
||||
|
||||
it('keeps the portal profile for a same-host stream on another port', () => {
|
||||
// Must match the renderer's classification: a same-host stream on a
|
||||
// different port stays portal-owned, or isStalkerDirectStreamProfile
|
||||
// would discard the renderer's credentialed headers for it.
|
||||
const streamUrl = 'http://same-host.example.test:8080/stream/1.ts';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl,
|
||||
portalUrl:
|
||||
'http://same-host.example.test/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
|
||||
|
||||
expect(headers['Cookie']).toContain(`mac=${macAddress}`);
|
||||
expect(headers['Authorization']).toBe('Bearer token-1');
|
||||
expect(headers['User-Agent']).not.toBe('KSPlayer');
|
||||
});
|
||||
|
||||
it('uses the credential-free direct profile for foreign hosts', () => {
|
||||
const streamUrl = 'http://cdn.foreign.example.test/stream/1.ts';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl,
|
||||
portalUrl:
|
||||
'http://portal.foreign-case.example.test/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
|
||||
|
||||
expect(headers['User-Agent']).toBe('KSPlayer');
|
||||
expect(headers).not.toHaveProperty('Cookie');
|
||||
expect(headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
it('uses the credential-free profile on an https→http downgrade', () => {
|
||||
const streamUrl = 'http://downgrade.example.test/stream/1.ts';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl,
|
||||
portalUrl:
|
||||
'https://downgrade.example.test/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
|
||||
|
||||
expect(headers['User-Agent']).toBe('KSPlayer');
|
||||
expect(headers).not.toHaveProperty('Cookie');
|
||||
expect(headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
STALKER_MAG_USER_AGENT,
|
||||
buildStalkerSerialCfduid,
|
||||
isStalkerStreamCredentialSafe,
|
||||
normalizeStalkerSerialNumber,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const STALKER_MAG_USER_AGENT =
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
|
||||
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
|
||||
const STALKER_STREAM_RANGE_HEADER = 'bytes=0-';
|
||||
|
||||
@@ -34,14 +34,20 @@ function normalizeStreamUrl(streamUrl: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getOrigin(streamUrl: string): string {
|
||||
function unwrapStreamUrl(streamUrl: string): string {
|
||||
const normalized = String(streamUrl ?? '').trim();
|
||||
if (!normalized) return '';
|
||||
const spaceIndex = normalized.indexOf(' ');
|
||||
const maybeWrapped =
|
||||
spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized;
|
||||
return spaceIndex > 0
|
||||
? normalized.slice(spaceIndex + 1).trim()
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function getOrigin(streamUrl: string): string {
|
||||
const unwrapped = unwrapStreamUrl(streamUrl);
|
||||
if (!unwrapped) return '';
|
||||
try {
|
||||
return new URL(maybeWrapped).origin;
|
||||
return new URL(unwrapped).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -91,10 +97,17 @@ export function rememberStalkerPlaybackContext(input: {
|
||||
portalOrigin = undefined;
|
||||
}
|
||||
|
||||
// Classified by the shared predicate so this fallback context can never
|
||||
// disagree with the renderer's header builder: same host (port change or
|
||||
// scheme upgrade included) keeps the portal profile, a foreign host or
|
||||
// an https→http downgrade gets the credential-free direct profile.
|
||||
const crossOriginStream =
|
||||
Boolean(streamOrigin) &&
|
||||
Boolean(portalOrigin) &&
|
||||
streamOrigin !== portalOrigin;
|
||||
!isStalkerStreamCredentialSafe(
|
||||
input.portalUrl,
|
||||
unwrapStreamUrl(input.streamUrl)
|
||||
);
|
||||
|
||||
const headers: Record<string, string> = crossOriginStream
|
||||
? {
|
||||
|
||||
@@ -25,9 +25,55 @@ nx run-many --targets=serve --projects=stalker-mock-server,web
|
||||
|
||||
Then in IPTVnator, add a new Stalker portal:
|
||||
|
||||
- **Portal URL**: `http://localhost:3210/portal.php`
|
||||
- **Portal URL**: `http://localhost:3210/portal.php` (tolerant panel-style endpoint)
|
||||
or `http://localhost:3210/stalker_portal/server/load.php` (canonical Ministra
|
||||
endpoint — see [Two endpoints](#two-endpoints-tolerant-vs-strict) below)
|
||||
- **MAC Address**: one of the predefined scenarios below (or any MAC for auto-generated data)
|
||||
|
||||
## Two endpoints: tolerant vs strict
|
||||
|
||||
The same actions are served at two paths with deliberately different strictness,
|
||||
because the app treats them differently: a URL containing `/stalker_portal` is
|
||||
imported as a **full portal** (handshake + token + watchdog), anything else as a
|
||||
**simple portal** (no authentication at all).
|
||||
|
||||
| Path | Behaviour |
|
||||
|---|---|
|
||||
| `/portal.php` | Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild. |
|
||||
| `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware. |
|
||||
| `/server/load.php` | Strict. The second full-portal URL shape the app recognizes; enforced identically. |
|
||||
|
||||
> **Known app inconsistency:** `StalkerSessionService.isFullStalkerPortal`
|
||||
> classifies `/server/load.php` as a full portal, but the import dialog's
|
||||
> `isFullStalkerPortalUrl` checks only for `/stalker_portal`, so importing a bare
|
||||
> `…/server/load.php` URL persists `isFullStalkerPortal: false` and the app skips
|
||||
> the handshake. The mock is deliberately faithful to a **real** portal here
|
||||
> (that path enforces auth), which makes it the right fixture to drive the
|
||||
> upcoming fix that unifies those two predicates. Until then, import full
|
||||
> portals through a `/stalker_portal/...` URL.
|
||||
|
||||
The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can
|
||||
actually get wrong:
|
||||
|
||||
- Every action except `handshake`, `get_profile`, `get_localization` and
|
||||
`do_auth` requires `Authorization: Bearer <token>`.
|
||||
- A token only counts once `get_profile` has adopted it — a handshake alone is
|
||||
not a session. Adoption is deliberately stricter than the stock server:
|
||||
only tokens the mock actually issued (or the already-bound one) are
|
||||
accepted, so a client with a broken token pipeline fails loudly.
|
||||
- Auth failures come back as **HTTP 200 with a plain-text body**
|
||||
(`Authorization failed.`, `Unauthorized request.`), never a 401/403. Clients
|
||||
that only check status codes will silently render nothing.
|
||||
- The handshake is **idempotent**: presenting the MAC's current token returns
|
||||
that same token instead of rotating it.
|
||||
- `device_id`/`device_id2` are pinned to the MAC on first non-empty value; any
|
||||
later change — including sending them empty again — is a permanent
|
||||
`device conflict` with the "Your STB is damaged." block message.
|
||||
- `signature`, `metrics` and `prehash` are accepted and ignored, exactly as the
|
||||
stock server does.
|
||||
- The MAC must match the Infomir OUI format (`00:1A:79:XX:XX:XX`) or
|
||||
`get_profile` answers with a bare `{ status: 1 }`.
|
||||
|
||||
## Predefined Scenario MAC Addresses
|
||||
|
||||
| MAC Address | Scenario | Description |
|
||||
@@ -40,6 +86,7 @@ Then in IPTVnator, add a new Stalker portal:
|
||||
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
|
||||
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
|
||||
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work |
|
||||
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
|
||||
|
||||
## Configuration
|
||||
@@ -54,7 +101,8 @@ Then in IPTVnator, add a new Stalker portal:
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/health` | `GET` | Health check — returns `{ status: "ok" }` |
|
||||
| `/reset` | `POST` | Clear all in-memory data and favorites (useful between test runs) |
|
||||
| `/reset` | `POST` | Clear all in-memory data, favorites, sessions and watchdog counters (useful between test runs) |
|
||||
| `/invalidate-session?macAddress=<mac>` | `POST` | Drop that MAC's tokens so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries. Pinned device identity survives, as on a real portal |
|
||||
|
||||
## API Coverage
|
||||
|
||||
@@ -62,8 +110,10 @@ All endpoints are served at `GET /portal.php?action=<action>&...` matching the r
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| `handshake` | Returns a mock Bearer token |
|
||||
| `do_auth` | Returns a mock user profile |
|
||||
| `handshake` | Issues the access token (idempotent) plus the 5.x `random` nonce and `not_valid` flag |
|
||||
| `get_profile` | Turns the handshake token into a session; enforces device-id pinning, and on the strict endpoint the MAC format |
|
||||
| `get_events` | Watchdog ping; records the call and returns an empty event set (never affects authorization, as on a real portal) |
|
||||
| `do_auth` | Boolean login step: `{js:true}` for non-empty credentials (recorded for the login-required scenario), `{js:false}` otherwise |
|
||||
| `get_categories` | Category list filtered by `type` (itv/vod/series) |
|
||||
| `get_genres` | Genre list (mirrors categories) |
|
||||
| `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons |
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Request } from 'express';
|
||||
import {
|
||||
adoptToken,
|
||||
checkRequestAuthorization,
|
||||
hasCompletedDoAuth,
|
||||
invalidateSession,
|
||||
issueHandshakeToken,
|
||||
markDoAuthCompleted,
|
||||
pinDeviceIdentity,
|
||||
readBearerToken,
|
||||
resetAuthState,
|
||||
} from './auth-store';
|
||||
|
||||
const MAC = '00:1A:79:AA:BB:CC';
|
||||
|
||||
function request(options: {
|
||||
action?: string;
|
||||
mac?: string | null;
|
||||
token?: string;
|
||||
}): Request {
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.mac !== null) {
|
||||
headers['cookie'] = `mac=${options.mac ?? MAC}; stb_lang=en`;
|
||||
}
|
||||
if (options.token) {
|
||||
headers['authorization'] = `Bearer ${options.token}`;
|
||||
}
|
||||
return {
|
||||
headers,
|
||||
query: { action: options.action ?? 'get_categories' },
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe('stalker mock auth store', () => {
|
||||
beforeEach(() => {
|
||||
resetAuthState();
|
||||
});
|
||||
|
||||
it('issues a 32-char uppercase hex token', () => {
|
||||
const { token } = issueHandshakeToken(MAC);
|
||||
|
||||
expect(token).toMatch(/^[0-9A-F]{32}$/);
|
||||
});
|
||||
|
||||
it('returns the stored token unchanged when it is presented again', () => {
|
||||
const { token } = issueHandshakeToken(MAC);
|
||||
adoptToken(MAC, token);
|
||||
|
||||
const second = issueHandshakeToken(MAC, token);
|
||||
|
||||
expect(second.token).toBe(token);
|
||||
expect(second.notValid).toBe(false);
|
||||
});
|
||||
|
||||
it('flags not_valid when a stale token is presented', () => {
|
||||
expect(issueHandshakeToken(MAC, 'STALE').notValid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a request without a mac cookie', () => {
|
||||
expect(checkRequestAuthorization(request({ mac: null }), true)).toBe(
|
||||
'Unauthorized request.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated action when enforcement is on', () => {
|
||||
expect(checkRequestAuthorization(request({}), true)).toBe(
|
||||
'Authorization failed.'
|
||||
);
|
||||
});
|
||||
|
||||
it('allows the handshake/profile/do_auth actions without a token', () => {
|
||||
for (const action of ['handshake', 'get_profile', 'do_auth']) {
|
||||
expect(checkRequestAuthorization(request({ action }), true)).toBe(
|
||||
null
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows any action once get_profile adopted the token', () => {
|
||||
const { token } = issueHandshakeToken(MAC);
|
||||
adoptToken(MAC, token);
|
||||
|
||||
expect(checkRequestAuthorization(request({ token }), true)).toBe(null);
|
||||
});
|
||||
|
||||
it('rejects a token that was never adopted by get_profile', () => {
|
||||
const { token } = issueHandshakeToken(MAC);
|
||||
|
||||
expect(checkRequestAuthorization(request({ token }), true)).toBe(
|
||||
'Authorization failed.'
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to adopt a token the handshake never issued', () => {
|
||||
issueHandshakeToken(MAC);
|
||||
|
||||
// Stricter than the stock server on purpose: a forged or stale token
|
||||
// must not become a session, or the mock cannot catch a client whose
|
||||
// token pipeline is broken.
|
||||
expect(adoptToken(MAC, 'F0RGEDF0RGEDF0RGEDF0RGEDF0RGED12')).toBe(false);
|
||||
expect(
|
||||
checkRequestAuthorization(
|
||||
request({ token: 'F0RGEDF0RGEDF0RGEDF0RGEDF0RGED12' }),
|
||||
true
|
||||
)
|
||||
).toBe('Authorization failed.');
|
||||
});
|
||||
|
||||
it('re-adopts the already-bound token', () => {
|
||||
const { token } = issueHandshakeToken(MAC);
|
||||
adoptToken(MAC, token);
|
||||
|
||||
expect(adoptToken(MAC, token)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails after the session is invalidated so clients must re-authenticate', () => {
|
||||
const { token } = issueHandshakeToken(MAC);
|
||||
adoptToken(MAC, token);
|
||||
invalidateSession(MAC);
|
||||
|
||||
expect(checkRequestAuthorization(request({ token }), true)).toBe(
|
||||
'Authorization failed.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps pinned device identity across session invalidation', () => {
|
||||
pinDeviceIdentity(MAC, 'dev-1', undefined);
|
||||
invalidateSession(MAC);
|
||||
|
||||
// Losing the token (another device logged in) never unpins device_id
|
||||
// on a real portal, so a changed identity must still conflict.
|
||||
expect(pinDeviceIdentity(MAC, 'other', undefined)).toBe(
|
||||
'device conflict - device_id mismatch'
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks do_auth completion per MAC', () => {
|
||||
expect(hasCompletedDoAuth(MAC)).toBe(false);
|
||||
|
||||
markDoAuthCompleted(MAC);
|
||||
|
||||
expect(hasCompletedDoAuth(MAC)).toBe(true);
|
||||
expect(hasCompletedDoAuth('00:1A:79:00:00:99')).toBe(false);
|
||||
});
|
||||
|
||||
it('never enforces the token when enforcement is off', () => {
|
||||
expect(checkRequestAuthorization(request({}), false)).toBe(null);
|
||||
});
|
||||
|
||||
it('reads the bearer token case-insensitively', () => {
|
||||
const req = {
|
||||
headers: { authorization: 'bearer ABC123 ' },
|
||||
} as unknown as Request;
|
||||
|
||||
expect(readBearerToken(req)).toBe('ABC123');
|
||||
});
|
||||
|
||||
describe('device identity pinning', () => {
|
||||
it('stores the first non-empty values', () => {
|
||||
expect(pinDeviceIdentity(MAC, 'dev-1', 'dev-2')).toBe(null);
|
||||
expect(pinDeviceIdentity(MAC, 'dev-1', 'dev-2')).toBe(null);
|
||||
});
|
||||
|
||||
it('reports a conflict when a pinned device_id changes', () => {
|
||||
pinDeviceIdentity(MAC, 'dev-1', undefined);
|
||||
|
||||
expect(pinDeviceIdentity(MAC, 'other', undefined)).toBe(
|
||||
'device conflict - device_id mismatch'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a conflict when a pinned value is later sent empty', () => {
|
||||
pinDeviceIdentity(MAC, 'dev-1', undefined);
|
||||
|
||||
// This is the real lockout: a client that stops sending the id it
|
||||
// once pinned is told its STB is damaged.
|
||||
expect(pinDeviceIdentity(MAC, undefined, undefined)).toBe(
|
||||
'device conflict - device_id mismatch'
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the device_id2 conflict separately', () => {
|
||||
pinDeviceIdentity(MAC, undefined, 'dev-2');
|
||||
|
||||
expect(pinDeviceIdentity(MAC, undefined, 'changed')).toBe(
|
||||
'device conflict - MAC address mismatch'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts identity omitted entirely on a fresh MAC', () => {
|
||||
expect(pinDeviceIdentity(MAC, undefined, undefined)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Request } from 'express';
|
||||
import { extractMac } from './request-mac.js';
|
||||
|
||||
/**
|
||||
* Session/identity state of the mocked portal.
|
||||
*
|
||||
* Modelled on the plaintext Stalker 4.9.35 middleware (`server/lib/stb.class.php`),
|
||||
* which is the last openly readable ancestor of the encoded 5.x core:
|
||||
*
|
||||
* - the handshake token is random and **idempotent** — re-presenting a valid
|
||||
* token returns the same one instead of rotating it
|
||||
* - a token only becomes a session once `get_profile` stores it for the MAC
|
||||
* - `device_id`/`device_id2` are pinned on first non-empty value and a later
|
||||
* mismatch is a hard, permanent conflict
|
||||
* - `signature`, `metrics` and `prehash` are accepted but never verified
|
||||
*/
|
||||
|
||||
interface PortalSession {
|
||||
/** Token handed out by the last handshake, before get_profile adopts it. */
|
||||
pendingToken?: string;
|
||||
/** Token stored for the MAC — what authorizes non-auth actions. */
|
||||
accessToken?: string;
|
||||
/** Whether do_auth completed with non-empty credentials for this MAC. */
|
||||
didAuth?: boolean;
|
||||
deviceId?: string;
|
||||
deviceId2?: string;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, PortalSession>();
|
||||
|
||||
/** Actions the portal answers without a valid Bearer token. */
|
||||
const UNAUTHENTICATED_ACTIONS = new Set([
|
||||
'handshake',
|
||||
'get_profile',
|
||||
'get_localization',
|
||||
'do_auth',
|
||||
]);
|
||||
|
||||
export type AuthFailure =
|
||||
| 'Authorization failed.'
|
||||
| 'Access denied.'
|
||||
| 'Unauthorized request.';
|
||||
|
||||
function getSession(mac: string): PortalSession {
|
||||
const key = mac.toLowerCase();
|
||||
if (!sessions.has(key)) {
|
||||
sessions.set(key, {});
|
||||
}
|
||||
return sessions.get(key) as PortalSession;
|
||||
}
|
||||
|
||||
/** 32 uppercase hex chars, like `strtoupper(md5(microtime + uniqid))`. */
|
||||
function generateToken(mac: string): string {
|
||||
const entropy = `${mac}:${sessions.size}:${tokenCounter++}`;
|
||||
let hex = '';
|
||||
for (let index = 0; index < 32; index += 1) {
|
||||
const code = entropy.charCodeAt(index % entropy.length) + index * 31;
|
||||
hex += (code % 16).toString(16).toUpperCase();
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
let tokenCounter = 0;
|
||||
|
||||
/**
|
||||
* Issue (or re-confirm) a handshake token. Presenting the MAC's current access
|
||||
* token returns it unchanged, which is what lets real clients persist tokens
|
||||
* across restarts.
|
||||
*/
|
||||
export function issueHandshakeToken(mac: string, presentedToken?: string): {
|
||||
token: string;
|
||||
notValid: boolean;
|
||||
} {
|
||||
const session = getSession(mac);
|
||||
|
||||
if (presentedToken && presentedToken === session.accessToken) {
|
||||
return { token: session.accessToken, notValid: false };
|
||||
}
|
||||
|
||||
const token = generateToken(mac);
|
||||
session.pendingToken = token;
|
||||
|
||||
// The real server only sets not_valid when an auth_url is configured and a
|
||||
// stale token was presented; mirroring the flag lets clients exercise it.
|
||||
return { token, notValid: Boolean(presentedToken) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the handshake token as the MAC's session token (what get_profile
|
||||
* does). Deliberately STRICTER than the stock server here: 4.9.35 issues
|
||||
* handshake tokens statelessly and pins whatever Bearer get_profile presents,
|
||||
* so a forged token would become a session on a real portal. The mock only
|
||||
* adopts tokens it actually issued (or the already-bound one), so a client
|
||||
* with a broken or substituted token pipeline fails loudly in tests instead
|
||||
* of passing by accident.
|
||||
*/
|
||||
export function adoptToken(mac: string, token: string): boolean {
|
||||
const session = getSession(mac);
|
||||
if (token !== session.pendingToken && token !== session.accessToken) {
|
||||
return false;
|
||||
}
|
||||
session.accessToken = token;
|
||||
session.pendingToken = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Record that do_auth completed with non-empty credentials for this MAC. */
|
||||
export function markDoAuthCompleted(mac: string): void {
|
||||
getSession(mac).didAuth = true;
|
||||
}
|
||||
|
||||
export function hasCompletedDoAuth(mac: string): boolean {
|
||||
return getSession(mac).didAuth === true;
|
||||
}
|
||||
|
||||
export function readBearerToken(req: Request): string | undefined {
|
||||
const header = req.headers['authorization'];
|
||||
if (typeof header !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
// `\s+(.*)` would backtrack polynomially on "bearer" + a long run of
|
||||
// spaces, so require the token to start with a non-space character.
|
||||
const match = /^Bearer[ \t]+(\S.*)$/i.exec(header.trim());
|
||||
return match?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin device identity to the MAC. Returns a conflict message when a previously
|
||||
* stored value is contradicted — including the "blank after pinned" case that
|
||||
* permanently locks real users out.
|
||||
*/
|
||||
export function pinDeviceIdentity(
|
||||
mac: string,
|
||||
deviceId: string | undefined,
|
||||
deviceId2: string | undefined
|
||||
): string | null {
|
||||
const session = getSession(mac);
|
||||
|
||||
for (const [field, incoming] of [
|
||||
['deviceId', deviceId],
|
||||
['deviceId2', deviceId2],
|
||||
] as const) {
|
||||
const stored = session[field];
|
||||
if (!stored) {
|
||||
if (incoming) {
|
||||
session[field] = incoming;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (stored !== (incoming ?? '')) {
|
||||
return field === 'deviceId'
|
||||
? 'device conflict - device_id mismatch'
|
||||
: 'device conflict - MAC address mismatch';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a request may proceed. `enforce` is false for the reseller-style
|
||||
* `/portal.php` endpoint, which commonly ignores tokens, and true for the
|
||||
* canonical `/stalker_portal/server/load.php` endpoint.
|
||||
*/
|
||||
export function checkRequestAuthorization(
|
||||
req: Request,
|
||||
enforce: boolean
|
||||
): AuthFailure | null {
|
||||
const action = String(req.query['action'] ?? '');
|
||||
const hasMacCookie = (req.headers['cookie'] ?? '').includes('mac=');
|
||||
|
||||
if (!hasMacCookie) {
|
||||
return 'Unauthorized request.';
|
||||
}
|
||||
if (!enforce || UNAUTHENTICATED_ACTIONS.has(action)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = getSession(extractMac(req));
|
||||
const presented = readBearerToken(req);
|
||||
|
||||
if (!session.accessToken || presented !== session.accessToken) {
|
||||
return 'Authorization failed.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the MAC's tokens so the next request must re-authenticate. Pinned
|
||||
* device identity survives on purpose: on a real portal a lost token (another
|
||||
* device logged in) never unpins device_id, so re-authenticating with a
|
||||
* changed identity must still hit the device-conflict branch.
|
||||
*/
|
||||
export function invalidateSession(mac: string): void {
|
||||
const session = sessions.get(mac.toLowerCase());
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
session.accessToken = undefined;
|
||||
session.pendingToken = undefined;
|
||||
}
|
||||
|
||||
export function resetAuthState(mac?: string): void {
|
||||
if (mac) {
|
||||
sessions.delete(mac.toLowerCase());
|
||||
return;
|
||||
}
|
||||
sessions.clear();
|
||||
tokenCounter = 0;
|
||||
}
|
||||
@@ -44,6 +44,17 @@ export function resetFavorites(mac: string): void {
|
||||
favoritesStore.delete(mac.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset everything cached for one MAC. Preferred over `resetAll()` in tests:
|
||||
* mock state is per-MAC, so a scoped reset cannot disturb a spec that runs
|
||||
* concurrently against a different MAC.
|
||||
*/
|
||||
export function resetMac(mac: string): void {
|
||||
const key = mac.toLowerCase();
|
||||
portalCache.delete(key);
|
||||
favoritesStore.delete(key);
|
||||
}
|
||||
|
||||
/** Reset all cached data (exposed via /reset endpoint). */
|
||||
export function resetAll(): void {
|
||||
portalCache.clear();
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
checkRequestAuthorization,
|
||||
resetAuthState,
|
||||
} from '../auth-store';
|
||||
import { handleDoAuth } from './do-auth.handler';
|
||||
import { handleGetEvents } from './get-events.handler';
|
||||
import { handleGetProfile } from './get-profile.handler';
|
||||
import { handleHandshake } from './handshake.handler';
|
||||
|
||||
/**
|
||||
* Handler-level coverage for the authentication actions.
|
||||
*
|
||||
* The e2e suite drives the app against this server, but the login-required
|
||||
* flow cannot be reached from the client yet (its `do_auth` path is dormant
|
||||
* and sends empty credentials), so the scenario is pinned down here rather
|
||||
* than only asserted in prose.
|
||||
*
|
||||
* Handlers are called directly instead of through the dispatcher: the
|
||||
* dispatcher pulls in every content handler and therefore the faker-based
|
||||
* data generator, which this project's Jest setup does not transform.
|
||||
*/
|
||||
|
||||
const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08';
|
||||
const PLAIN_MAC = '00:1A:79:00:00:01';
|
||||
const BAD_FORMAT_MAC = 'AA:BB:CC:DD:EE:01';
|
||||
|
||||
type Handler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
options?: { enforceMacFormat?: boolean }
|
||||
) => void;
|
||||
|
||||
function invoke(
|
||||
handler: Handler,
|
||||
query: Record<string, string>,
|
||||
options: {
|
||||
mac?: string;
|
||||
token?: string;
|
||||
enforceMacFormat?: boolean;
|
||||
} = {}
|
||||
): Record<string, unknown> {
|
||||
const headers: Record<string, string> = {
|
||||
cookie: `mac=${options.mac ?? PLAIN_MAC}`,
|
||||
};
|
||||
if (options.token) {
|
||||
headers['authorization'] = `Bearer ${options.token}`;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
const req = { query, headers, params: {} } as unknown as Request;
|
||||
const res = {
|
||||
json: (data: unknown) => {
|
||||
body = data;
|
||||
},
|
||||
} as unknown as Response;
|
||||
|
||||
handler(req, res, { enforceMacFormat: options.enforceMacFormat ?? true });
|
||||
|
||||
return (body as { js: Record<string, unknown> }).js;
|
||||
}
|
||||
|
||||
function contentRequest(mac: string, token?: string): Request {
|
||||
const headers: Record<string, string> = { cookie: `mac=${mac}` };
|
||||
if (token) {
|
||||
headers['authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return {
|
||||
headers,
|
||||
query: { action: 'get_categories' },
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe('stalker mock authentication handlers', () => {
|
||||
beforeEach(() => {
|
||||
resetAuthState();
|
||||
});
|
||||
|
||||
it('walks the login-required scenario: status 2 -> do_auth -> profile', () => {
|
||||
const token = invoke(handleHandshake, { action: 'handshake' }, {
|
||||
mac: LOGIN_REQUIRED_MAC,
|
||||
})['token'] as string;
|
||||
expect(token).toMatch(/^[0-9A-F]{32}$/);
|
||||
|
||||
// The app sends auth_second_step=1 on its very first profile request,
|
||||
// so that parameter alone must not satisfy the scenario.
|
||||
expect(
|
||||
invoke(
|
||||
handleGetProfile,
|
||||
{ action: 'get_profile', auth_second_step: '1' },
|
||||
{ mac: LOGIN_REQUIRED_MAC, token }
|
||||
)['status']
|
||||
).toBe(2);
|
||||
|
||||
// Empty credentials are refused, as the operator billing script would.
|
||||
expect(
|
||||
invoke(
|
||||
handleDoAuth,
|
||||
{ action: 'do_auth', login: '', password: '' },
|
||||
{ mac: LOGIN_REQUIRED_MAC }
|
||||
) as unknown
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
invoke(
|
||||
handleDoAuth,
|
||||
{ action: 'do_auth', login: 'user', password: 'secret' },
|
||||
{ mac: LOGIN_REQUIRED_MAC }
|
||||
) as unknown
|
||||
).toBe(true);
|
||||
|
||||
const profile = invoke(
|
||||
handleGetProfile,
|
||||
{ action: 'get_profile' },
|
||||
{ mac: LOGIN_REQUIRED_MAC, token }
|
||||
);
|
||||
expect(profile['status']).toBe(0);
|
||||
expect(profile['watchdog_timeout']).toBe(120);
|
||||
|
||||
// Only now is the token adopted, so protected actions are authorized.
|
||||
expect(
|
||||
checkRequestAuthorization(
|
||||
contentRequest(LOGIN_REQUIRED_MAC, token),
|
||||
true
|
||||
)
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('rejects a non-Infomir MAC and never adopts its token', () => {
|
||||
const token = invoke(handleHandshake, { action: 'handshake' }, {
|
||||
mac: BAD_FORMAT_MAC,
|
||||
})['token'] as string;
|
||||
|
||||
expect(
|
||||
invoke(
|
||||
handleGetProfile,
|
||||
{ action: 'get_profile' },
|
||||
{ mac: BAD_FORMAT_MAC, token }
|
||||
)
|
||||
).toEqual({ status: 1 });
|
||||
|
||||
expect(
|
||||
checkRequestAuthorization(contentRequest(BAD_FORMAT_MAC, token), true)
|
||||
).toBe('Authorization failed.');
|
||||
});
|
||||
|
||||
it('accepts that MAC on the tolerant endpoint, which skips format checks', () => {
|
||||
const token = invoke(handleHandshake, { action: 'handshake' }, {
|
||||
mac: BAD_FORMAT_MAC,
|
||||
})['token'] as string;
|
||||
|
||||
expect(
|
||||
invoke(
|
||||
handleGetProfile,
|
||||
{ action: 'get_profile' },
|
||||
{ mac: BAD_FORMAT_MAC, token, enforceMacFormat: false }
|
||||
)['status']
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a device conflict once an id was pinned', () => {
|
||||
const token = invoke(handleHandshake, { action: 'handshake' })[
|
||||
'token'
|
||||
] as string;
|
||||
invoke(
|
||||
handleGetProfile,
|
||||
{ action: 'get_profile', device_id: 'dev-A' },
|
||||
{ token }
|
||||
);
|
||||
|
||||
const conflict = invoke(
|
||||
handleGetProfile,
|
||||
{ action: 'get_profile', device_id: 'dev-B' },
|
||||
{ token }
|
||||
);
|
||||
expect(conflict['msg']).toBe('device conflict - device_id mismatch');
|
||||
expect(conflict['block_msg']).toContain('Your STB is damaged');
|
||||
});
|
||||
|
||||
it('returns the handshake nonce and re-confirms an adopted token', () => {
|
||||
const first = invoke(handleHandshake, { action: 'handshake' });
|
||||
const token = first['token'] as string;
|
||||
expect(first['random']).toEqual(expect.any(String));
|
||||
expect(first['not_valid']).toBe(0);
|
||||
|
||||
invoke(handleGetProfile, { action: 'get_profile' }, { token });
|
||||
|
||||
// Presenting the adopted token returns it unchanged — this is what lets
|
||||
// real clients persist a token across restarts.
|
||||
const second = invoke(handleHandshake, {
|
||||
action: 'handshake',
|
||||
token,
|
||||
});
|
||||
expect(second['token']).toBe(token);
|
||||
expect(second['not_valid']).toBe(0);
|
||||
});
|
||||
|
||||
it('answers the watchdog with an empty event set', () => {
|
||||
expect(
|
||||
invoke(handleGetEvents, {
|
||||
action: 'get_events',
|
||||
type: 'watchdog',
|
||||
init: '1',
|
||||
})
|
||||
).toEqual({ data: { msgs: 0, additional_services_on: '1' } });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { resolveStreamUrl } from '../data-generator.js';
|
||||
import { extractMac } from './get-categories.handler.js';
|
||||
import { buildRequestOrigin } from '../marketing-poster-url.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
import { getScenario } from '../scenarios.js';
|
||||
|
||||
/**
|
||||
* Stalker create_link — returns a playable stream URL.
|
||||
@@ -18,7 +20,21 @@ export function handleCreateLink(req: Request, res: Response): void {
|
||||
const itemIndex = cmd
|
||||
.split('')
|
||||
.reduce((acc, ch) => acc + ch.charCodeAt(0), 0);
|
||||
const streamUrl = resolveStreamUrl(cmd, itemIndex);
|
||||
// The /stalker proxy route dispatches a synthetic request without
|
||||
// Express' .get(), so fall back to the Host header there instead of
|
||||
// crashing — the gated scenario is only meaningful for direct clients
|
||||
// that can attach credentials to media requests anyway.
|
||||
const requestOrigin =
|
||||
typeof req.get === 'function'
|
||||
? buildRequestOrigin(req)
|
||||
: `http://${req.headers['host'] ?? 'localhost:3210'}`;
|
||||
// Radio plays in an <audio> element, which needs a fixture with an audio
|
||||
// track; ITV/VOD get the video fixture.
|
||||
const gatedFile =
|
||||
req.query['type'] === 'radio' ? 'audio.mp4' : 'video.mp4';
|
||||
const streamUrl = getScenario(mac).gatedStream
|
||||
? `${requestOrigin}/stream/gated/${gatedFile}`
|
||||
: resolveStreamUrl(cmd, itemIndex);
|
||||
|
||||
console.log(`[create_link] MAC=${mac} cmd=${cmd} → ${streamUrl}`);
|
||||
|
||||
@@ -28,6 +44,13 @@ export function handleCreateLink(req: Request, res: Response): void {
|
||||
streamer_id: '1',
|
||||
load: '',
|
||||
error: '',
|
||||
// Mock-only diagnostics (absent from real portal responses): what
|
||||
// this request actually delivered after Express' single query
|
||||
// decode — the same view a PHP portal gets from $_GET. E2E uses
|
||||
// them to pin the cmd wire contract (no double-encoding, no
|
||||
// query-parameter injection through cmd).
|
||||
cmd_received: cmd,
|
||||
query_keys_received: Object.keys(req.query).sort(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { markDoAuthCompleted } from '../auth-store.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
/**
|
||||
* Stalker do_auth — returns user profile / account info.
|
||||
* Stalker do_auth — the login/password step behind `get_profile` status 2.
|
||||
*
|
||||
* The real portal never checks a password itself: it proxies the credentials
|
||||
* to the operator's billing script and answers a bare boolean. The mock
|
||||
* accepts any non-empty pair, records the completion so `get_profile` can
|
||||
* stop answering `status: 2` for the login-required scenario, and rejects
|
||||
* empty credentials the way a billing script would.
|
||||
*/
|
||||
export function handleDoAuth(req: Request, res: Response): void {
|
||||
res.json({
|
||||
js: {
|
||||
id: '1',
|
||||
name: 'Mock User',
|
||||
login: 'mockuser',
|
||||
password: '',
|
||||
status: 'active',
|
||||
tariff_expired_date: '2099-12-31',
|
||||
phone: '',
|
||||
ls: '0',
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
blocked: '0',
|
||||
acc_enabled: '1',
|
||||
max_connections: '1',
|
||||
active_connections: '0',
|
||||
},
|
||||
});
|
||||
const login = String(req.query['login'] ?? '');
|
||||
const password = String(req.query['password'] ?? '');
|
||||
|
||||
if (!login || !password) {
|
||||
res.json({ js: false });
|
||||
return;
|
||||
}
|
||||
|
||||
markDoAuthCompleted(extractMac(req));
|
||||
res.json({ js: true });
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getPortalData,
|
||||
removeFavorite,
|
||||
} from '../data-store.js';
|
||||
import { extractMac } from './get-categories.handler.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
import {
|
||||
RawChannel,
|
||||
RawRadioStation,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
import { getScenario } from '../scenarios.js';
|
||||
import { extractMac } from './get-categories.handler.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
/**
|
||||
* Stalker/Ministra get_all_channels — returns the COMPLETE ITV channel list
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
/**
|
||||
* Stalker get_categories — returns category list filtered by type.
|
||||
@@ -22,14 +23,3 @@ export function handleGetCategories(req: Request, res: Response): void {
|
||||
|
||||
res.json({ js: categories });
|
||||
}
|
||||
|
||||
export function extractMac(req: Request): string {
|
||||
const cookie = req.headers['cookie'] ?? '';
|
||||
return (
|
||||
cookie
|
||||
.split(';')
|
||||
.find((c) => c.trim().startsWith('mac='))
|
||||
?.split('=')[1]
|
||||
?.trim() ?? '00:00:00:00:00:00'
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
import { extractMac } from './get-categories.handler.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
/**
|
||||
* Stalker get_epg_info — returns bulk EPG keyed by channel id.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
/** Per-MAC watchdog bookkeeping so tests can assert the client pings at all. */
|
||||
const watchdogPings = new Map<string, { count: number; lastInit: string }>();
|
||||
|
||||
/**
|
||||
* Stalker watchdog `get_events`. The real portal only uses it for presence
|
||||
* reporting and event delivery — it never affects authorization — so the mock
|
||||
* records the ping and returns an empty event set.
|
||||
*/
|
||||
export function handleGetEvents(req: Request, res: Response): void {
|
||||
const mac = extractMac(req).toLowerCase();
|
||||
const init = String(req.query['init'] ?? '0');
|
||||
const previous = watchdogPings.get(mac);
|
||||
|
||||
watchdogPings.set(mac, {
|
||||
count: (previous?.count ?? 0) + 1,
|
||||
lastInit: init,
|
||||
});
|
||||
|
||||
res.json({
|
||||
js: {
|
||||
data: {
|
||||
msgs: 0,
|
||||
additional_services_on: '1',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getWatchdogPings(
|
||||
mac: string
|
||||
): { count: number; lastInit: string } | undefined {
|
||||
return watchdogPings.get(mac.toLowerCase());
|
||||
}
|
||||
|
||||
export function resetWatchdogPings(mac?: string): void {
|
||||
if (mac) {
|
||||
watchdogPings.delete(mac.toLowerCase());
|
||||
return;
|
||||
}
|
||||
watchdogPings.clear();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
import { extractMac } from './get-categories.handler.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
/**
|
||||
* Stalker get_genres — returns genre list for a content type.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Stalker `account_info/get_main_info` — subscription facts for the
|
||||
* account-info dialog. Uses the nested `js.account_info` envelope that
|
||||
* Ministra-style portals answer with (the shape `fetchStalkerExpireDate()`
|
||||
* already consumes), plus flat aliases some panels emit alongside it.
|
||||
*/
|
||||
export function handleGetMainInfo(req: Request, res: Response): void {
|
||||
const expiry = new Date();
|
||||
expiry.setDate(expiry.getDate() + 180);
|
||||
const mac = (req.query['mac'] as string) || '00:1A:79:00:00:01';
|
||||
|
||||
res.json({
|
||||
js: {
|
||||
mac,
|
||||
phone: '10042',
|
||||
account_info: {
|
||||
login: 'mockuser',
|
||||
status: 1,
|
||||
tariff_plan: 'Mock Premium 180',
|
||||
end_date: expiry.toISOString().slice(0, 10),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
import { extractMac } from './get-categories.handler.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
import {
|
||||
RawChannel,
|
||||
RawRadioStation,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
adoptToken,
|
||||
hasCompletedDoAuth,
|
||||
pinDeviceIdentity,
|
||||
readBearerToken,
|
||||
} from '../auth-store.js';
|
||||
import { getScenario } from '../scenarios.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
/** `00:1A:79` is the Infomir OUI the stock portal requires by default. */
|
||||
const INFOMIR_MAC = /^00:1A:79:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}$/;
|
||||
|
||||
/**
|
||||
* Stalker get_profile — the step that turns a handshake token into a session.
|
||||
*
|
||||
* Reproduces the outcomes of the 4.9.35 middleware: a bare `{status:1}` for a
|
||||
* malformed MAC, `{status:1, msg, block_msg}` for a device conflict,
|
||||
* `{status:2}` when the portal wants login/password, and otherwise the profile
|
||||
* itself. `signature`, `metrics` and `prehash` are accepted and ignored, which
|
||||
* is exactly what the real server does.
|
||||
*/
|
||||
export function handleGetProfile(
|
||||
req: Request,
|
||||
res: Response,
|
||||
options: { enforceMacFormat?: boolean } = {}
|
||||
): void {
|
||||
const mac = extractMac(req);
|
||||
const scenario = getScenario(mac);
|
||||
|
||||
if (options.enforceMacFormat && !INFOMIR_MAC.test(mac.toUpperCase())) {
|
||||
res.json({ js: { status: 1 } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Gate on the actual do_auth completion, not on auth_second_step: the app
|
||||
// sends auth_second_step=1 on its very first get_profile, so a parameter
|
||||
// check would let the login-required flow be bypassed without ever
|
||||
// exercising status 2 -> do_auth -> profile retry.
|
||||
if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) {
|
||||
res.json({
|
||||
js: {
|
||||
status: 2,
|
||||
template: 'auth',
|
||||
info: 'Login required',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const conflict = pinDeviceIdentity(
|
||||
mac,
|
||||
String(req.query['device_id'] ?? '') || undefined,
|
||||
String(req.query['device_id2'] ?? '') || undefined
|
||||
);
|
||||
|
||||
if (conflict) {
|
||||
res.json({
|
||||
js: {
|
||||
status: 1,
|
||||
msg: conflict,
|
||||
block_msg: 'Your STB is damaged.<br/> Call the provider.',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = readBearerToken(req);
|
||||
if (token) {
|
||||
adoptToken(mac, token);
|
||||
}
|
||||
|
||||
res.json({
|
||||
js: {
|
||||
id: '1',
|
||||
name: 'Mock STB',
|
||||
mac,
|
||||
status: 0,
|
||||
blocked: '0',
|
||||
fname: 'Mock User',
|
||||
login: 'mockuser',
|
||||
stb_type: String(req.query['stb_type'] ?? ''),
|
||||
hd: String(req.query['hd'] ?? '1'),
|
||||
// Clients should take their watchdog cadence from these two values
|
||||
// rather than hardcoding one.
|
||||
watchdog_timeout: 120,
|
||||
timeslot: 15,
|
||||
tariff_plan_id: '1',
|
||||
tariff_expired_date: '2099-12-31',
|
||||
locale: 'en',
|
||||
default_locale: 'en',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
|
||||
import { generateSeasons } from '../data-generator.js';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
import { getScenario } from '../scenarios.js';
|
||||
import { extractMac } from './get-categories.handler.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
/**
|
||||
* Stalker get_ordered_list with type=series for seasons/episodes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { generateEpg } from '../data-generator.js';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
import { extractMac } from './get-categories.handler.js';
|
||||
import { extractMac } from '../request-mac.js';
|
||||
|
||||
/**
|
||||
* Stalker get_short_epg — returns EPG programs for a channel.
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { issueHandshakeToken } from '../auth-store.js';
|
||||
|
||||
/**
|
||||
* Stalker handshake — returns a Bearer token.
|
||||
* Real portals return a JWT; we return a deterministic fake token.
|
||||
* Stalker handshake — issues the access token.
|
||||
*
|
||||
* Like the real middleware the token is opaque and idempotent: presenting the
|
||||
* MAC's current token returns it unchanged instead of rotating it. `random` is
|
||||
* the 5.x nonce a real MAG signs into `signature`; the mock returns it so
|
||||
* clients that read it are exercised.
|
||||
*/
|
||||
export function handleHandshake(req: Request, res: Response): void {
|
||||
const mac = (req.headers['cookie'] ?? '')
|
||||
@@ -11,9 +16,14 @@ export function handleHandshake(req: Request, res: Response): void {
|
||||
?.split('=')[1]
|
||||
?.trim() ?? 'unknown';
|
||||
|
||||
const presented = String(req.query['token'] ?? '') || undefined;
|
||||
const { token, notValid } = issueHandshakeToken(mac, presented);
|
||||
|
||||
res.json({
|
||||
js: {
|
||||
token: `mock-token-${Buffer.from(mac).toString('base64')}`,
|
||||
token,
|
||||
random: `${token.slice(0, 20).toLowerCase()}0123456789abcdef1234`,
|
||||
not_valid: notValid ? 1 : 0,
|
||||
keep_alive: 180,
|
||||
servertime: Math.floor(Date.now() / 1000),
|
||||
servertimezone: 'Europe/Berlin',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Read the MAC the portal identifies the box by. Real Stalker clients send it
|
||||
* as a `mac=` cookie on every request; the proxy route synthesizes the same
|
||||
* cookie from its query parameter.
|
||||
*/
|
||||
export function extractMac(req: Request): string {
|
||||
const cookie = req.headers['cookie'] ?? '';
|
||||
return (
|
||||
cookie
|
||||
.split(';')
|
||||
.find((c) => c.trim().startsWith('mac='))
|
||||
?.split('=')[1]
|
||||
?.trim() ?? '00:00:00:00:00:00'
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { checkRequestAuthorization } from '../auth-store.js';
|
||||
import { handleHandshake } from '../handlers/handshake.handler.js';
|
||||
import { handleDoAuth } from '../handlers/do-auth.handler.js';
|
||||
import { handleGetEvents } from '../handlers/get-events.handler.js';
|
||||
import { handleGetProfile } from '../handlers/get-profile.handler.js';
|
||||
import { handleGetAllChannels } from '../handlers/get-all-channels.handler.js';
|
||||
import { handleGetCategories } from '../handlers/get-categories.handler.js';
|
||||
import { handleGetOrderedList } from '../handlers/get-ordered-list.handler.js';
|
||||
@@ -10,21 +13,60 @@ import { handleFavorites } from '../handlers/favorites.handler.js';
|
||||
import { handleGetEpgInfo } from '../handlers/get-epg-info.handler.js';
|
||||
import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js';
|
||||
import { handleGetGenres } from '../handlers/get-genres.handler.js';
|
||||
import { handleGetMainInfo } from '../handlers/get-main-info.handler.js';
|
||||
|
||||
export interface DispatchOptions {
|
||||
/**
|
||||
* Whether the endpoint enforces the Bearer token. The canonical
|
||||
* `/stalker_portal/server/load.php` path does (like a real portal); the
|
||||
* reseller-style `/portal.php` alias does not, which is what most panels in
|
||||
* the wild behave like and what the existing e2e suite relies on.
|
||||
*/
|
||||
enforceAuth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Stalker action dispatcher.
|
||||
* Used by both the direct /portal.php route and the /stalker CORS proxy route.
|
||||
* Used by the direct portal routes and the /stalker CORS proxy route.
|
||||
*/
|
||||
export default function dispatchPortalAction(req: Request, res: Response): void {
|
||||
export default function dispatchPortalAction(
|
||||
req: Request,
|
||||
res: Response,
|
||||
options: DispatchOptions = {}
|
||||
): void {
|
||||
const action = req.query['action'] as string;
|
||||
|
||||
const authFailure = checkRequestAuthorization(
|
||||
req,
|
||||
options.enforceAuth ?? false
|
||||
);
|
||||
if (authFailure) {
|
||||
// The real middleware echoes this as a plain-text body with HTTP 200 —
|
||||
// never a 401/403 — because it exits before the JSON envelope is built.
|
||||
res.status(200).type('html').send(authFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'handshake':
|
||||
handleHandshake(req, res);
|
||||
break;
|
||||
case 'get_profile':
|
||||
handleGetProfile(req, res, {
|
||||
// A real portal validates the MAC format by default; the
|
||||
// tolerant reseller alias does not.
|
||||
enforceMacFormat: options.enforceAuth ?? false,
|
||||
});
|
||||
break;
|
||||
case 'get_events':
|
||||
handleGetEvents(req, res);
|
||||
break;
|
||||
case 'do_auth':
|
||||
handleDoAuth(req, res);
|
||||
break;
|
||||
case 'get_main_info':
|
||||
handleGetMainInfo(req, res);
|
||||
break;
|
||||
case 'get_categories':
|
||||
case 'get_genres_vod':
|
||||
case 'get_genres_itv':
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import dispatchPortalAction from './dispatch.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Main Stalker API dispatcher.
|
||||
* All requests arrive as GET /portal.php?action=<action>&...
|
||||
*
|
||||
* Two endpoints are served with the same actions but different strictness:
|
||||
* `/portal.php` (reseller-panel alias, tolerant) and
|
||||
* `/stalker_portal/server/load.php` (canonical Ministra path, enforces the
|
||||
* Bearer token exactly like the real middleware).
|
||||
*/
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
dispatchPortalAction(req, res);
|
||||
});
|
||||
export function createPortalRouter(enforceAuth: boolean): Router {
|
||||
const router = Router();
|
||||
|
||||
export default router;
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
dispatchPortalAction(req, res, { enforceAuth });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createPortalRouter(false);
|
||||
@@ -23,6 +23,15 @@ export interface ScenarioConfig {
|
||||
supportsGetAllChannels?: boolean;
|
||||
/** Replace generated VOD with the shared screenshot-safe poster catalog. */
|
||||
marketingFixture?: true;
|
||||
/** Answer `get_profile` with `status: 2` until `auth_second_step=1`. */
|
||||
requiresLogin?: true;
|
||||
/**
|
||||
* `create_link` returns this server's own `/stream/gated/video.mp4`,
|
||||
* which answers 403 unless the request carries the portal mac cookie AND
|
||||
* the MAC's Bearer token — proves a player's media requests really carry
|
||||
* the portal credentials (the "only VLC works" cluster).
|
||||
*/
|
||||
gatedStream?: true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,6 +118,19 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
embeddedSeriesFraction: 0,
|
||||
supportsGetAllChannels: false,
|
||||
},
|
||||
'00:1a:79:00:00:08': {
|
||||
name: 'login-required',
|
||||
description:
|
||||
'Portal answering get_profile with status 2 until do_auth completes',
|
||||
seed: 8008,
|
||||
categoryCount: { itv: 4, radio: 4, vod: 4, series: 4 },
|
||||
itemsPerCategory: 10,
|
||||
seasonsPerSeries: 2,
|
||||
episodesPerSeason: 4,
|
||||
isSeriesFraction: 0,
|
||||
embeddedSeriesFraction: 0,
|
||||
requiresLogin: true,
|
||||
},
|
||||
'00:1a:79:00:00:07': {
|
||||
name: 'marketing-demo',
|
||||
description: 'Screenshot-safe portal with 35 original poster movies',
|
||||
@@ -121,6 +143,20 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
embeddedSeriesFraction: 0,
|
||||
marketingFixture: true,
|
||||
},
|
||||
'00:1a:79:00:00:09': {
|
||||
name: 'gated-stream',
|
||||
description:
|
||||
'Streams gated on portal credentials — create_link returns a ' +
|
||||
'local URL that 403s without the mac cookie + Bearer token',
|
||||
seed: 9009,
|
||||
categoryCount: { itv: 2, radio: 1, vod: 1, series: 1 },
|
||||
itemsPerCategory: 5,
|
||||
seasonsPerSeries: 1,
|
||||
episodesPerSeason: 3,
|
||||
isSeriesFraction: 0,
|
||||
embeddedSeriesFraction: 0,
|
||||
gatedStream: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,16 @@ import http from 'http';
|
||||
import { join } from 'node:path';
|
||||
import express, { Request, Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import portalRouter from './app/routes/portal.route.js';
|
||||
import { buildStalkerIdentityRequestContext } from '@iptvnator/shared/interfaces';
|
||||
import portalRouter, { createPortalRouter } from './app/routes/portal.route.js';
|
||||
import dispatchPortalAction from './app/routes/dispatch.js';
|
||||
import { resetAll } from './app/data-store.js';
|
||||
import {
|
||||
checkRequestAuthorization,
|
||||
invalidateSession,
|
||||
resetAuthState,
|
||||
} from './app/auth-store.js';
|
||||
import { resetWatchdogPings } from './app/handlers/get-events.handler.js';
|
||||
import { resetAll, resetMac } from './app/data-store.js';
|
||||
import { SCENARIOS } from './app/scenarios.js';
|
||||
import {
|
||||
buildRequestOrigin,
|
||||
@@ -12,6 +19,10 @@ import {
|
||||
} from './app/marketing-poster-url.js';
|
||||
|
||||
const PORT = parseInt(process.env['PORT'] ?? '3210', 10);
|
||||
// Loopback by default: the fixture serves fabricated but unauthenticated
|
||||
// content, so it should not be reachable from other hosts unless a dev
|
||||
// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at it).
|
||||
const HOST = process.env['HOST'] ?? '127.0.0.1';
|
||||
const app = express();
|
||||
const MARKETING_POSTER_DIRECTORY = join(
|
||||
process.cwd(),
|
||||
@@ -76,9 +87,26 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
// Stalker portal.php endpoint (direct portal protocol, Electron mode)
|
||||
// Stalker portal.php endpoint (reseller-panel alias — tolerant, no token check)
|
||||
app.use('/portal.php', portalRouter);
|
||||
|
||||
// Canonical Ministra endpoints — enforce the Bearer token and the MAC format
|
||||
// exactly like the real middleware, so the full-portal auth flow is testable.
|
||||
// Both URL shapes the app classifies as "full" must land on the strict branch.
|
||||
app.use('/stalker_portal/server/load.php', createPortalRouter(true));
|
||||
app.use('/server/load.php', createPortalRouter(true));
|
||||
|
||||
/**
|
||||
* Mirror of the app's full-portal predicates (`isFullStalkerPortal` checks
|
||||
* `/stalker_portal/` or `/server/load.php`; import-time normalization checks
|
||||
* `/stalker_portal`). Any URL shape the client would authenticate against must
|
||||
* be enforced by the proxy too, or tests would silently exercise the tolerant
|
||||
* branch.
|
||||
*/
|
||||
function isFullPortalUrlShape(url: string): boolean {
|
||||
return url.includes('/stalker_portal') || url.includes('/server/load.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape:
|
||||
* GET /stalker?url=<portal_url>&macAddress=<mac>&action=<action>&...
|
||||
@@ -89,27 +117,143 @@ app.use('/portal.php', portalRouter);
|
||||
* so no app code changes are required.
|
||||
*/
|
||||
app.get('/stalker', (req: Request, res: Response) => {
|
||||
const { macAddress, url: _url, ...rest } = req.query as Record<string, string>;
|
||||
const mac = macAddress ?? '00:1a:79:00:00:01';
|
||||
const {
|
||||
macAddress,
|
||||
token,
|
||||
serialNumber,
|
||||
url: portalUrl,
|
||||
...rest
|
||||
} = req.query as Record<string, unknown>;
|
||||
|
||||
// A repeated query key arrives as an array, so every value used below must
|
||||
// be narrowed to a string before it reaches a string API.
|
||||
const asString = (value: unknown): string | undefined =>
|
||||
typeof value === 'string' ? value : undefined;
|
||||
|
||||
const mac = asString(macAddress) ?? '00:1a:79:00:00:01';
|
||||
const bearer = asString(token);
|
||||
|
||||
// Mirror of the real backend proxy: `macAddress`, `token` and
|
||||
// `serialNumber` are control params turned into the portal-facing
|
||||
// Cookie / Authorization / SN headers and STRIPPED from the query the
|
||||
// portal sees — with one protocol exception, `handshake`, which presents
|
||||
// its candidate token as a query param (that keeps the
|
||||
// idempotent-handshake path testable). The shared identity builder is the
|
||||
// same code the real proxy and the Electron transport run, so this mirror
|
||||
// cannot drift from them.
|
||||
const query: Record<string, unknown> = { ...rest };
|
||||
if (query['action'] === 'handshake' && bearer) {
|
||||
query['token'] = bearer;
|
||||
}
|
||||
const identity = buildStalkerIdentityRequestContext({
|
||||
macAddress: mac,
|
||||
params: query as Record<string, string | number>,
|
||||
...(bearer ? { token: bearer } : {}),
|
||||
...(asString(serialNumber)
|
||||
? { serialNumber: asString(serialNumber) }
|
||||
: {}),
|
||||
});
|
||||
// The real proxy appends this while building the portal URL; mirror it so
|
||||
// `query_keys_received` diagnostics match what a real portal would log.
|
||||
if (!identity.requestParams['JsHttpRequest']) {
|
||||
identity.requestParams['JsHttpRequest'] = '1-xml';
|
||||
}
|
||||
|
||||
// Forward the COMPLETE identity header set (Cookie, MAG UA pair, SN,
|
||||
// Authorization, Accept/Language/Connection), lowercased the way Express
|
||||
// normalizes incoming headers, so portal handlers can validate any header
|
||||
// the real proxy sends — not just the cookie and token.
|
||||
const headers: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(identity.headers).map(([key, value]) => [
|
||||
key.toLowerCase(),
|
||||
value,
|
||||
])
|
||||
);
|
||||
|
||||
// Build a lightweight synthetic request. We need a fresh object with mutable
|
||||
// `query` and a Cookie header containing the MAC for the handler helpers.
|
||||
const syntheticReq = {
|
||||
query: rest,
|
||||
headers: { cookie: `mac=${mac}` },
|
||||
query: identity.requestParams,
|
||||
headers,
|
||||
params: {},
|
||||
} as unknown as Request;
|
||||
|
||||
// Capture the JSON response and wrap it in the proxy envelope { payload: ... }
|
||||
let captured: unknown;
|
||||
let plainTextBody: string | undefined;
|
||||
const syntheticRes = {
|
||||
json: (data: unknown) => {
|
||||
captured = data;
|
||||
},
|
||||
} as unknown as Response;
|
||||
status: () => syntheticRes,
|
||||
type: () => syntheticRes,
|
||||
send: (body: string) => {
|
||||
plainTextBody = body;
|
||||
},
|
||||
} as unknown as Response & { send: (body: string) => void };
|
||||
|
||||
dispatchPortalAction(syntheticReq, syntheticRes);
|
||||
res.json({ payload: captured });
|
||||
dispatchPortalAction(syntheticReq, syntheticRes, {
|
||||
// The proxied portal URL decides strictness, matching the direct
|
||||
// endpoints: every canonical Ministra path shape enforces the token.
|
||||
enforceAuth: isFullPortalUrlShape(asString(portalUrl) ?? ''),
|
||||
});
|
||||
|
||||
// The portal answers auth failures with a plain-text body; the real backend
|
||||
// proxy still wraps whatever it got in the { payload } envelope, so the
|
||||
// renderer sees the raw string there rather than a transport error.
|
||||
res.json({ payload: plainTextBody ?? captured });
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth-gated media endpoints for the `gated-stream` scenario. A real portal's
|
||||
* streamer sits behind the same session gate as the API, so these routes
|
||||
* require the mac cookie AND the MAC's Bearer token and answer 403
|
||||
* otherwise. They are the only automated proof that a player's actual media
|
||||
* requests carry the portal credentials — a unit test cannot show that a
|
||||
* header reached the video (or audio) element.
|
||||
*
|
||||
* The bodies are the shared clear (non-DRM) fragmented-MP4 fixtures from the
|
||||
* DASH e2e suite (video for ITV, audio-only for radio); `sendFile` supplies
|
||||
* Range support for progressive playback.
|
||||
*/
|
||||
const GATED_STREAM_FIXTURES: Record<string, string> = {
|
||||
'audio.mp4': join(
|
||||
process.cwd(),
|
||||
'apps/web-e2e/src/fixtures/dash/clear-audio.mp4'
|
||||
),
|
||||
'video.mp4': join(
|
||||
process.cwd(),
|
||||
'apps/web-e2e/src/fixtures/dash/clear-video.mp4'
|
||||
),
|
||||
};
|
||||
|
||||
app.get('/stream/gated/:file', (req: Request, res: Response) => {
|
||||
const fixture = GATED_STREAM_FIXTURES[req.params['file'] ?? ''];
|
||||
if (!fixture) {
|
||||
res.status(404).type('text/plain').send('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const failure = checkRequestAuthorization(req, true);
|
||||
if (failure) {
|
||||
// Log only header PRESENCE: the cookie carries the mac session
|
||||
// credential and must never reach terminal/CI logs verbatim.
|
||||
console.log(
|
||||
`[gated-stream] 403 (${failure}) cookie=${
|
||||
req.headers['cookie'] ? 'present' : '<none>'
|
||||
} auth=${req.headers['authorization'] ? 'present' : '<none>'}`
|
||||
);
|
||||
res.status(403).type('text/plain').send(failure);
|
||||
return;
|
||||
}
|
||||
|
||||
// `dotfiles: 'allow'`: express refuses any path with a dot-segment by
|
||||
// default, and git worktrees live under `.claude/worktrees/…` — without
|
||||
// this the fixture 404s in every worktree checkout.
|
||||
res.sendFile(fixture, {
|
||||
dotfiles: 'allow',
|
||||
headers: { 'Content-Type': 'video/mp4' },
|
||||
});
|
||||
});
|
||||
|
||||
// Health check
|
||||
@@ -117,10 +261,56 @@ app.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Reset all in-memory data (useful between Playwright test runs)
|
||||
app.post('/reset', (_req: Request, res: Response) => {
|
||||
resetAll();
|
||||
res.json({ status: 'reset', timestamp: new Date().toISOString() });
|
||||
/**
|
||||
* Reset in-memory state between test runs.
|
||||
*
|
||||
* `?macAddress=<mac>` scopes the reset to that MAC and is what specs should
|
||||
* use: mock state is per-MAC, so a scoped reset cannot wipe the session of a
|
||||
* spec file running concurrently in another Playwright worker. Without the
|
||||
* parameter everything is cleared, which is only safe when nothing else is
|
||||
* talking to this server.
|
||||
*/
|
||||
app.post('/reset', (req: Request, res: Response) => {
|
||||
const macParam = req.query['macAddress'];
|
||||
// Repeated `macAddress` params let a suite clear all of its MACs in one
|
||||
// request instead of one round trip each.
|
||||
const macs = (Array.isArray(macParam) ? macParam : [macParam]).filter(
|
||||
(value): value is string => typeof value === 'string' && value !== ''
|
||||
);
|
||||
|
||||
if (macs.length > 0) {
|
||||
for (const mac of macs) {
|
||||
resetMac(mac);
|
||||
resetAuthState(mac);
|
||||
resetWatchdogPings(mac);
|
||||
}
|
||||
} else {
|
||||
resetAll();
|
||||
resetAuthState();
|
||||
resetWatchdogPings();
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'reset',
|
||||
...(macs.length > 0 ? { macs } : {}),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Drop a MAC's session so the next portal request fails with
|
||||
* `Authorization failed.` — lets e2e assert the client re-handshakes and
|
||||
* retries instead of surfacing an error.
|
||||
*/
|
||||
app.post('/invalidate-session', (req: Request, res: Response) => {
|
||||
const macParam = req.query['macAddress'];
|
||||
const mac = typeof macParam === 'string' ? macParam : '';
|
||||
if (!mac) {
|
||||
res.status(400).json({ error: 'macAddress query param is required' });
|
||||
return;
|
||||
}
|
||||
invalidateSession(mac);
|
||||
res.json({ status: 'invalidated', mac });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -158,7 +348,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
process.stdin.resume();
|
||||
process.stdin.on('end', () => { /* ignore stdin close */ });
|
||||
|
||||
server.listen(PORT, () => {
|
||||
server.listen(PORT, HOST, () => {
|
||||
const divider = '─'.repeat(62);
|
||||
console.log(`\n${divider}`);
|
||||
console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`);
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { Server } from 'node:http';
|
||||
import { STALKER_MAG_USER_AGENT } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
createWebBackendApp,
|
||||
WebBackendHttpClient,
|
||||
WebBackendHttpGetOptions,
|
||||
} from './web-backend-app';
|
||||
|
||||
/** The transport-identity headers every portal-facing Stalker request carries. */
|
||||
const STALKER_IDENTITY_HEADERS = {
|
||||
'User-Agent': STALKER_MAG_USER_AGENT,
|
||||
'X-User-Agent': STALKER_MAG_USER_AGENT,
|
||||
Accept: '*/*',
|
||||
Connection: 'keep-alive',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
};
|
||||
|
||||
interface HttpRequest {
|
||||
readonly headers?: Record<string, string>;
|
||||
readonly params?: Record<string, string>;
|
||||
@@ -349,7 +359,7 @@ https://stream.example/live.m3u8`);
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies Stalker requests with MAC cookie and bearer token', async () => {
|
||||
it('proxies Stalker requests with the full STB identity and no credentials in the portal query', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse({ js: [{ id: '2001', title: 'Action' }] });
|
||||
|
||||
@@ -371,25 +381,219 @@ https://stream.example/live.m3u8`);
|
||||
action: 'get_categories',
|
||||
payload: { js: [{ id: '2001', title: 'Action' }] },
|
||||
});
|
||||
// MAC and token travel ONLY as Cookie/Authorization — the
|
||||
// portal query carries protocol params plus the JsHttpRequest
|
||||
// marker every real client sends.
|
||||
expect(httpClient.requests).toEqual([
|
||||
{
|
||||
headers: {
|
||||
...STALKER_IDENTITY_HEADERS,
|
||||
Authorization: 'Bearer abc123',
|
||||
Cookie: 'mac=00:1A:79:00:00:01',
|
||||
Cookie: 'mac=00:1A:79:00:00:01; stb_lang=en_US@rg=dezzzz; timezone=Europe/Berlin',
|
||||
},
|
||||
params: {
|
||||
action: 'get_categories',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
token: 'abc123',
|
||||
type: 'vod',
|
||||
},
|
||||
url: 'http://stalker.example/portal.php',
|
||||
params: undefined,
|
||||
url: 'http://stalker.example/portal.php?action=get_categories&type=vod&JsHttpRequest=1-xml',
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the SN header, cfduid cookie, and get_profile sn param for a stored serial', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse({ js: { status: 0 } });
|
||||
httpClient.queueResponse({ js: [] });
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
httpClient,
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://stalker.example/portal.php'
|
||||
);
|
||||
await fetch(
|
||||
`${baseUrl}/stalker?targetId=${targetId}&macAddress=00:1A:79:00:00:01&token=abc123&serialNumber=SN1234&type=stb&action=get_profile`
|
||||
);
|
||||
// A non-profile action must not carry the serial as a query
|
||||
// param even when the renderer sends a stale `sn`.
|
||||
await fetch(
|
||||
`${baseUrl}/stalker?targetId=${targetId}&macAddress=00:1A:79:00:00:01&token=abc123&serialNumber=SN1234&type=itv&action=get_ordered_list&sn=SN1234`
|
||||
);
|
||||
|
||||
const [profileRequest, listRequest] = httpClient.requests;
|
||||
expect(profileRequest.headers).toMatchObject({
|
||||
SN: 'SN1234',
|
||||
});
|
||||
expect(profileRequest.headers?.['Cookie']).toContain(
|
||||
'__cfduid='
|
||||
);
|
||||
expect(profileRequest.url).toBe(
|
||||
'http://stalker.example/portal.php?type=stb&action=get_profile&sn=SN1234&JsHttpRequest=1-xml'
|
||||
);
|
||||
|
||||
expect(listRequest.headers).toMatchObject({ SN: 'SN1234' });
|
||||
expect(listRequest.url).toBe(
|
||||
'http://stalker.example/portal.php?type=itv&action=get_ordered_list&JsHttpRequest=1-xml'
|
||||
);
|
||||
for (const request of httpClient.requests) {
|
||||
expect(request.url).not.toContain('serialNumber');
|
||||
expect(request.url).not.toContain('macAddress');
|
||||
expect(request.url).not.toContain('token=');
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the presented handshake token in the portal query', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse({ js: { token: 'FRESH' } });
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
httpClient,
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://stalker.example/portal.php'
|
||||
);
|
||||
// Handshake is the one action whose token is protocol
|
||||
// content: the portal reads the candidate from the query and
|
||||
// returns it unchanged when it is still valid.
|
||||
await fetch(
|
||||
`${baseUrl}/stalker?targetId=${targetId}&macAddress=00:1A:79:00:00:01&token=CACHEDTOKEN123&type=stb&action=handshake`
|
||||
);
|
||||
|
||||
expect(httpClient.requests[0].url).toBe(
|
||||
'http://stalker.example/portal.php?type=stb&action=handshake&token=CACHEDTOKEN123&JsHttpRequest=1-xml'
|
||||
);
|
||||
expect(httpClient.requests[0].headers).toMatchObject({
|
||||
Authorization: 'Bearer CACHEDTOKEN123',
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the session cookie when no MAC is supplied', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse({ js: [] });
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
httpClient,
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://stalker.example/portal.php'
|
||||
);
|
||||
await fetch(
|
||||
`${baseUrl}/stalker?targetId=${targetId}&action=get_categories`
|
||||
);
|
||||
|
||||
expect(
|
||||
httpClient.requests[0].headers
|
||||
).not.toHaveProperty('Cookie');
|
||||
expect(httpClient.requests[0].headers).toMatchObject(
|
||||
STALKER_IDENTITY_HEADERS
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards Stalker cmd in the reference wire format instead of axios encoding', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse({ js: { cmd: 'http://cdn/stream.m3u8' } });
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
httpClient,
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://stalker.example/portal.php'
|
||||
);
|
||||
// The PWA renderer sends fully URLSearchParams-encoded values;
|
||||
// Express decodes them back to the stored cmd string.
|
||||
const query = new URLSearchParams({
|
||||
targetId,
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
action: 'create_link',
|
||||
type: 'itv',
|
||||
cmd: 'ffrt3 http://host/ch/123?token=a%3Ab c&x=1',
|
||||
});
|
||||
const response = await fetch(
|
||||
`${baseUrl}/stalker?${query.toString()}`
|
||||
);
|
||||
await response.json();
|
||||
|
||||
// Slashes stay raw and pre-encoded sequences pass through
|
||||
// untouched (no %25 double-encoding); '&' inside cmd cannot
|
||||
// append query parameters. The whole query is built by the
|
||||
// shared URL builder, so nothing rides in axios params.
|
||||
expect(httpClient.requests).toEqual([
|
||||
{
|
||||
headers: {
|
||||
...STALKER_IDENTITY_HEADERS,
|
||||
Cookie: 'mac=00:1A:79:00:00:01; stb_lang=en_US@rg=dezzzz; timezone=Europe/Berlin',
|
||||
},
|
||||
params: undefined,
|
||||
url:
|
||||
'http://stalker.example/portal.php' +
|
||||
'?action=create_link&type=itv' +
|
||||
'&cmd=ffrt3%20http://host/ch/123?token=a%3Ab%20c%26x=1' +
|
||||
'&JsHttpRequest=1-xml',
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps cmd in the query when the registered portal URL carries a fragment or bare ?', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse({ js: { cmd: 'http://cdn/a.m3u8' } });
|
||||
httpClient.queueResponse({ js: { cmd: 'http://cdn/b.m3u8' } });
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
httpClient,
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
// A fragment on the registered URL must not swallow the
|
||||
// appended cmd (fragments are never sent to the portal).
|
||||
const fragmentTarget = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://stalker.example/portal.php#legacy'
|
||||
);
|
||||
await fetch(
|
||||
`${baseUrl}/stalker?targetId=${fragmentTarget}&action=create_link&cmd=${encodeURIComponent('/media/1.mpg')}`
|
||||
);
|
||||
|
||||
// A trailing bare '?' must not produce '??cmd='.
|
||||
const bareQueryTarget = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://stalker.example/load.php?'
|
||||
);
|
||||
await fetch(
|
||||
`${baseUrl}/stalker?targetId=${bareQueryTarget}&action=create_link&cmd=${encodeURIComponent('/media/2.mpg')}`
|
||||
);
|
||||
|
||||
expect(httpClient.requests.map((request) => request.url)).toEqual([
|
||||
'http://stalker.example/portal.php?action=create_link&cmd=/media/1.mpg&JsHttpRequest=1-xml',
|
||||
'http://stalker.example/load.php?action=create_link&cmd=/media/2.mpg&JsHttpRequest=1-xml',
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes provider errors for portal proxy calls', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueFailure(403, 'Forbidden');
|
||||
|
||||
@@ -7,7 +7,11 @@ import zlib from 'node:zlib';
|
||||
import axios from 'axios';
|
||||
import epgParser from 'epg-parser';
|
||||
import parser from 'iptv-playlist-parser';
|
||||
import { normalizeXtreamServerUrl } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildStalkerIdentityRequestContext,
|
||||
buildStalkerRequestUrl,
|
||||
normalizeXtreamServerUrl,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils';
|
||||
|
||||
export interface WebBackendHttpGetOptions {
|
||||
@@ -220,20 +224,50 @@ export function createWebBackendApp(
|
||||
const url = getRegisteredProviderUrl(req, res, providerTargets);
|
||||
const macAddress = getQueryString(req, 'macAddress');
|
||||
const token = getQueryString(req, 'token');
|
||||
const serialNumber = getQueryString(req, 'serialNumber');
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// `macAddress`, `token` and `serialNumber` are portal credentials,
|
||||
// not protocol content: they reach the portal only as the same
|
||||
// Cookie / Authorization / SN headers the Electron transport
|
||||
// sends, never in the portal's query string (which lands in
|
||||
// portal and intermediary access logs). The one protocol
|
||||
// exception is `handshake`, which presents its candidate token as
|
||||
// a query param and is answered without authentication.
|
||||
const params: Record<string, string | number> = getProxyParams(
|
||||
req,
|
||||
['targetId', 'macAddress', 'token', 'serialNumber']
|
||||
);
|
||||
if (params['action'] === 'handshake' && token) {
|
||||
params['token'] = token;
|
||||
}
|
||||
|
||||
// Shared with the Electron transport: full STB cookie, MAG
|
||||
// User-Agent pair, `sn` only on get_profile, `JsHttpRequest`
|
||||
// defaulting, and the reference `cmd` encoding (raw slashes,
|
||||
// pre-encoded sequences untouched, `&`/`#` still escaped).
|
||||
const identity = buildStalkerIdentityRequestContext({
|
||||
macAddress: macAddress ?? '',
|
||||
params,
|
||||
...(token ? { token } : {}),
|
||||
...(serialNumber ? { serialNumber } : {}),
|
||||
});
|
||||
const headers = { ...identity.headers };
|
||||
if (!macAddress) {
|
||||
// Tolerate credential-less calls the way the route always
|
||||
// has: no MAC means no session cookie, not an empty `mac=`.
|
||||
delete headers['Cookie'];
|
||||
}
|
||||
|
||||
// Provider URLs are validated by /provider-targets before they enter the registry.
|
||||
// codeql[js/request-forgery]
|
||||
const response = await httpClient.get(url.href, {
|
||||
params: getProxyParams(req, ['targetId']),
|
||||
headers: {
|
||||
...(macAddress ? { Cookie: `mac=${macAddress}` } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
const response = await httpClient.get(
|
||||
buildStalkerRequestUrl(url.href, identity.requestParams),
|
||||
{ headers }
|
||||
);
|
||||
|
||||
res.json({
|
||||
action: getQueryString(req, 'action'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
import type { APIRequestContext, Locator } from '@playwright/test';
|
||||
import { expect } from './fixtures';
|
||||
|
||||
export async function setInputValue(
|
||||
@@ -18,3 +18,37 @@ export async function setInputValue(
|
||||
await input.type(value);
|
||||
await expect(input).toHaveValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST to a mock-server control endpoint, retrying transport errors.
|
||||
*
|
||||
* The mock servers are shared by every spec file and Playwright runs those
|
||||
* files in parallel workers, so a burst of concurrent control requests can
|
||||
* occasionally be met with ECONNRESET. That is a transport hiccup, not a
|
||||
* failure of the test under it.
|
||||
*/
|
||||
export async function postWithRetry(
|
||||
request: APIRequestContext,
|
||||
url: string,
|
||||
attempts = 3
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await request.post(url);
|
||||
if (response.ok()) {
|
||||
return;
|
||||
}
|
||||
lastError = new Error(`POST ${url} failed: ${response.status()}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 250 * (attempt + 1))
|
||||
);
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
interceptPwaProviderRequests,
|
||||
resetPwaMockServers,
|
||||
} from './sources-pwa.helpers';
|
||||
|
||||
/**
|
||||
* Mobile Layout Smoke Tests
|
||||
*
|
||||
* PR #1326 made the workspace usable on phone-sized screens (issue #1100);
|
||||
* the follow-up drawer PR turned the phone context panel into an off-canvas
|
||||
* drawer. These tests pin the invariants that regressed before:
|
||||
*
|
||||
* 1. No horizontal overflow — document.documentElement.scrollWidth stays
|
||||
* within the viewport on dashboard, Xtream VOD/live, and settings.
|
||||
* 2. The workspace rail links render inside the 52px top bar instead of
|
||||
* stacking downwards over the header.
|
||||
* 3. On a portal route the context panel is an off-canvas drawer: hidden
|
||||
* by default so the content keeps the full viewport width, opened from
|
||||
* the header toggle (winning over the persisted desktop inline width),
|
||||
* and closed again by picking a category or tapping the backdrop.
|
||||
* 4. The settings section list scrolls instead of painting over the
|
||||
* Back footer — now inside the open drawer.
|
||||
* 5. On a 640x360 landscape phone the live route keeps the channel
|
||||
* sidebar at least 72px tall and the player container inside the
|
||||
* viewport.
|
||||
*
|
||||
* Conventions: docs/architecture/iptvnator-ui-guidelines.md, "Phone Layout"
|
||||
* (640px breakpoint, resizable rails stack full-width via `!important`).
|
||||
*
|
||||
* Tag: @mobile — run only this spec with:
|
||||
* pnpm nx run web-e2e:e2e-ci--src/mobile-layout.e2e.ts
|
||||
*/
|
||||
|
||||
const PHONE = { width: 375, height: 812 };
|
||||
const LANDSCAPE_PHONE = { width: 640, height: 360 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function expectNoHorizontalOverflow(
|
||||
page: Page,
|
||||
viewportWidth: number
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
||||
.toBeLessThanOrEqual(viewportWidth);
|
||||
}
|
||||
|
||||
async function boxOf(
|
||||
locator: Locator
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> {
|
||||
const box = await locator.boundingBox();
|
||||
expect(box, 'expected element to have a bounding box').not.toBeNull();
|
||||
return box as NonNullable<typeof box>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Before #1326 the nested `.rail-links` list kept `flex-direction: column`
|
||||
* inside the horizontal phone bar, so links 2..n were laid out below the
|
||||
* 52px row and overlapped the header. Horizontal overflow inside the bar
|
||||
* is fine — the bar scrolls sideways by design — so only y is asserted.
|
||||
*/
|
||||
async function expectRailLinksInsideTopBar(page: Page): Promise<void> {
|
||||
const rail = page.locator('.app-rail');
|
||||
await expect(rail).toBeVisible();
|
||||
|
||||
const railBox = await boxOf(rail);
|
||||
// The shell grid gives the rail row 52px on phones.
|
||||
expect(railBox.height).toBeLessThanOrEqual(60);
|
||||
|
||||
await expect(page.locator('.rail-links').first()).toHaveCSS(
|
||||
'flex-direction',
|
||||
'row'
|
||||
);
|
||||
|
||||
const links = page.locator('.app-rail a:visible');
|
||||
const count = await links.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const linkBox = await boxOf(links.nth(i));
|
||||
expect(linkBox.y).toBeGreaterThanOrEqual(railBox.y - 1);
|
||||
expect(linkBox.y + linkBox.height).toBeLessThanOrEqual(
|
||||
railBox.y + railBox.height + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Portrait phone — no imported sources needed, app boots at phone size
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('portrait phone 375x812', () => {
|
||||
test.use({ viewport: PHONE });
|
||||
|
||||
test('@mobile dashboard fits the viewport and keeps rail links in the top bar', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/workspace/dashboard');
|
||||
await expect(page.locator('.app-rail')).toBeVisible();
|
||||
|
||||
await expectNoHorizontalOverflow(page, PHONE.width);
|
||||
await expectRailLinksInsideTopBar(page);
|
||||
});
|
||||
|
||||
test('@mobile settings drawer opens from the header toggle and keeps the section list clear of the Back footer', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/workspace/settings');
|
||||
|
||||
// The phone context panel is an off-canvas drawer: hidden until the
|
||||
// header toggle opens it, so the settings content owns the pane.
|
||||
const panel = page.locator('.context-panel--settings');
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
await page.locator('[data-test-id="context-drawer-toggle"]').click();
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
// Narrower than the viewport so the backdrop stays tappable, and
|
||||
// wider than the persisted desktop inline width would leave it.
|
||||
const panelBox = await boxOf(panel);
|
||||
expect(panelBox.width).toBeGreaterThanOrEqual(300);
|
||||
expect(panelBox.width).toBeLessThanOrEqual(PHONE.width - 20);
|
||||
|
||||
const footer = panel.locator('.settings-panel-footer');
|
||||
await expect(footer.locator('.settings-back-button')).toBeVisible();
|
||||
|
||||
// Before #1326 the section list kept its full content height and
|
||||
// painted over the footer whenever the panel was shorter than its
|
||||
// sections; now the list scrolls and ends above the footer.
|
||||
const listBox = await boxOf(panel.locator('.settings-sections-list'));
|
||||
const footerBox = await boxOf(footer);
|
||||
expect(listBox.y + listBox.height).toBeLessThanOrEqual(
|
||||
footerBox.y + 1
|
||||
);
|
||||
|
||||
// Tapping the backdrop (right of the drawer) closes it.
|
||||
await page
|
||||
.locator('[data-test-id="context-drawer-backdrop"]')
|
||||
.click({ position: { x: PHONE.width - 10, y: 400 } });
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
await expectNoHorizontalOverflow(page, PHONE.width);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Xtream portal routes — the portal is imported at the default desktop
|
||||
// viewport (which persists desktop rail widths, the exact regression
|
||||
// scenario from #1100), then the window shrinks to phone size.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('xtream portal routes on a phone', () => {
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await resetPwaMockServers(request);
|
||||
await interceptPwaProviderRequests(page);
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page, 'Mobile Layout Portal');
|
||||
});
|
||||
|
||||
test('@mobile @xtream vod route keeps the context panel in a drawer behind the header toggle', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
|
||||
// Hidden by default — the content owns the full pane. This is the
|
||||
// successor to the #1326 stacked layout, which left the content
|
||||
// only the leftover under a 30vh panel.
|
||||
const panel = page.locator('.context-panel');
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
const content = page.locator('main.workspace-content');
|
||||
const contentWidth = await content.evaluate((el) => el.clientWidth);
|
||||
expect(contentWidth).toBeGreaterThanOrEqual(PHONE.width - 20);
|
||||
|
||||
// The header toggle slides the drawer in. The persisted desktop
|
||||
// width is written as an inline style; the drawer rule must win
|
||||
// with `width: 100% !important` of its ~320px surface, otherwise
|
||||
// the panel keeps its desktop width fraction.
|
||||
await page.locator('[data-test-id="context-drawer-toggle"]').click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await panel.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(300);
|
||||
|
||||
// Picking a category both filters the route and closes the drawer.
|
||||
const firstCategory = page
|
||||
.locator('.context-panel .category-item')
|
||||
.first();
|
||||
await expect(firstCategory).toBeVisible();
|
||||
await firstCategory.click();
|
||||
await expect(panel).toBeHidden();
|
||||
|
||||
await expectNoHorizontalOverflow(page, PHONE.width);
|
||||
await expectRailLinksInsideTopBar(page);
|
||||
});
|
||||
|
||||
test('@mobile @xtream landscape live route keeps the channel list and the player inside the viewport', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Select a category at desktop width so the channel sidebar renders
|
||||
// (`showLiveChannelSidebar` requires a selected category). On the
|
||||
// live root the click updates store state without navigating, so the
|
||||
// sidebar appearing is the completion signal — not a URL change.
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/live'));
|
||||
const firstCategory = page
|
||||
.locator('.context-panel .category-item')
|
||||
.first();
|
||||
await expect(firstCategory).toBeVisible();
|
||||
await firstCategory.click();
|
||||
|
||||
const sidebar = page.locator('app-live-stream-layout .sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await page.setViewportSize(LANDSCAPE_PHONE);
|
||||
|
||||
// The sidebar stacks at full width above the player.
|
||||
await expect
|
||||
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(LANDSCAPE_PHONE.width - 2);
|
||||
|
||||
// Before the follow-up fix in #1326 the player's 240px floor drove
|
||||
// the still-expanded sidebar to zero height on a 640x360 screen,
|
||||
// leaving no way to pick another channel.
|
||||
const sidebarBox = await boxOf(sidebar);
|
||||
expect(sidebarBox.height).toBeGreaterThanOrEqual(71);
|
||||
|
||||
// ...and pushed the layout past the viewport. The player container
|
||||
// has to end inside it.
|
||||
const contentBox = await boxOf(
|
||||
page.locator('app-live-stream-layout .content-container')
|
||||
);
|
||||
expect(contentBox.y + contentBox.height).toBeLessThanOrEqual(
|
||||
LANDSCAPE_PHONE.height + 1
|
||||
);
|
||||
expect(contentBox.width).toBeLessThanOrEqual(LANDSCAPE_PHONE.width);
|
||||
|
||||
await expectNoHorizontalOverflow(page, LANDSCAPE_PHONE.width);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import { postWithRetry, setInputValue } from './e2e-helpers';
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
const WEB_BACKEND_URL = 'http://localhost:3333';
|
||||
@@ -8,7 +8,9 @@ const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
|
||||
const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
const STALKER_MOCK_SERVER = `http://localhost:${STALKER_MOCK_PORT}`;
|
||||
const STALKER_PORTAL_URL = `${STALKER_MOCK_SERVER}/portal.php`;
|
||||
const DEFAULT_MAC = '00:1A:79:00:00:01';
|
||||
// Dedicated MAC: mock state is per-MAC and stalker.e2e.ts runs in a parallel
|
||||
// worker, so sharing one would let each suite's reset clear the other's state.
|
||||
const DEFAULT_MAC = '00:1A:79:5F:00:01';
|
||||
|
||||
async function installRuntimeConfig(page: Page): Promise<void> {
|
||||
await page.route('**/assets/app-config.js', async (route) => {
|
||||
@@ -101,8 +103,15 @@ function expectRequestsUseTargetId(requests: string[], path: string): void {
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await request.post(`${XTREAM_MOCK_SERVER}/reset`);
|
||||
await request.post(`${STALKER_MOCK_SERVER}/reset`);
|
||||
await postWithRetry(request, `${XTREAM_MOCK_SERVER}/reset`);
|
||||
// Scope the Stalker reset to the MAC this file uses: a global reset would
|
||||
// wipe the sessions of stalker.e2e.ts running in a parallel worker.
|
||||
await postWithRetry(
|
||||
request,
|
||||
`${STALKER_MOCK_SERVER}/reset?macAddress=${encodeURIComponent(
|
||||
DEFAULT_MAC
|
||||
)}`
|
||||
);
|
||||
await installRuntimeConfig(page);
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect } from './fixtures';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import { postWithRetry, setInputValue } from './e2e-helpers';
|
||||
import {
|
||||
getRegisteredProviderUrl,
|
||||
interceptProviderTargetRegistration,
|
||||
@@ -13,9 +13,11 @@ const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
|
||||
export const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
export const STALKER_MOCK_SERVER = `http://localhost:${STALKER_MOCK_PORT}`;
|
||||
export const STALKER_PORTAL_URL = `${STALKER_MOCK_SERVER}/portal.php`;
|
||||
export const EDITED_MAC = '00:1A:79:00:00:03';
|
||||
export const EDITED_MAC = '00:1A:79:5F:00:03';
|
||||
|
||||
const DEFAULT_MAC = '00:1A:79:00:00:01';
|
||||
// Dedicated MACs (see EDITED_MAC): never share a MAC with stalker.e2e.ts,
|
||||
// whose parallel worker would otherwise have its state reset mid-test.
|
||||
const DEFAULT_MAC = '00:1A:79:5F:00:02';
|
||||
const M3U_PLAYLIST_URL = `${XTREAM_MOCK_SERVER}/playlist.m3u`;
|
||||
|
||||
type RuntimeErrors = {
|
||||
@@ -31,11 +33,22 @@ type SourceDialogField =
|
||||
| 'title'
|
||||
| 'username';
|
||||
|
||||
/**
|
||||
* Reset the fixtures this suite uses. The Stalker reset is scoped to the MACs
|
||||
* touched here: that mock is shared with stalker.e2e.ts, which runs in a
|
||||
* parallel Playwright worker, and a global reset would destroy its portal
|
||||
* sessions mid-test.
|
||||
*/
|
||||
export async function resetPwaMockServers(
|
||||
request: APIRequestContext
|
||||
): Promise<void> {
|
||||
await request.post(`${XTREAM_MOCK_SERVER}/reset`);
|
||||
await request.post(`${STALKER_MOCK_SERVER}/reset`);
|
||||
await postWithRetry(request, `${XTREAM_MOCK_SERVER}/reset`);
|
||||
for (const mac of [DEFAULT_MAC, EDITED_MAC]) {
|
||||
await postWithRetry(
|
||||
request,
|
||||
`${STALKER_MOCK_SERVER}/reset?macAddress=${encodeURIComponent(mac)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function interceptPwaProviderRequests(
|
||||
|
||||
@@ -22,11 +22,35 @@ import {
|
||||
* - 3 seasons × 8 episodes per series item
|
||||
*
|
||||
* Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker"
|
||||
*
|
||||
* ISOLATION works on two levels, because one mock-server process is shared by
|
||||
* every spec file and its state is keyed by MAC:
|
||||
*
|
||||
* - ACROSS FILES: `beforeEach` resets only the MACs in `OWNED_MACS`, and the
|
||||
* sibling files that touch this server (self-hosted, sources-pwa) own a
|
||||
* disjoint `00:1A:79:5F:*` range, so neither can clear the other's state.
|
||||
* - WITHIN THIS FILE: tests deliberately share scenario MACs (`default`,
|
||||
* `minimal`, `embedded-series` — their fixture shapes are what the
|
||||
* assertions are written against), and every `beforeEach` resets all of
|
||||
* them. Under the workspace-wide `fullyParallel` preset that would let one
|
||||
* test wipe another's data or session mid-run, so the file pins itself to a
|
||||
* single worker.
|
||||
*
|
||||
* Giving each test its own MAC instead would mean inventing a scenario per
|
||||
* test; serializing one file is the cheaper trade.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
|
||||
const MOCK_SERVER = `http://localhost:${MOCK_PORT}`;
|
||||
const PORTAL_URL = `${MOCK_SERVER}/portal.php`;
|
||||
/**
|
||||
* Canonical Ministra path. `PORTAL_URL` above is classified by the app as a
|
||||
* "simple" portal (no handshake, no token, no watchdog); this shape is the
|
||||
* authenticated branch, which the mock guards like the real middleware.
|
||||
*/
|
||||
const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`;
|
||||
const BACKEND_PROXY = `${MOCK_SERVER}/stalker`;
|
||||
|
||||
/** Default scenario MAC — balanced catalog, 8 categories, 40 items */
|
||||
@@ -41,6 +65,20 @@ const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
|
||||
/** Legacy pagination MAC — portal without get_all_channels support */
|
||||
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
|
||||
|
||||
/**
|
||||
* Dedicated MACs for the full-portal authentication tests. Mock state is keyed
|
||||
* by MAC, so keeping these distinct from the content scenarios above means an
|
||||
* auth test can never consume or invalidate a session another test relies on.
|
||||
* The Infomir OUI matters: the strict endpoint validates the MAC format.
|
||||
*/
|
||||
const AUTH_FLOW_MAC = '00:1A:79:AD:00:01';
|
||||
const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03';
|
||||
/**
|
||||
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
|
||||
* it, so no token is ever adopted and content requests fail permanently.
|
||||
*/
|
||||
const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -76,12 +114,32 @@ async function interceptStalkerRequests(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Every MAC this file owns; all are cleared in one batched reset request. */
|
||||
const OWNED_MACS = [
|
||||
DEFAULT_MAC,
|
||||
MINIMAL_MAC,
|
||||
EMBEDDED_SERIES_MAC,
|
||||
LEGACY_PAGINATION_MAC,
|
||||
AUTH_FLOW_MAC,
|
||||
AUTH_REAUTH_MAC,
|
||||
AUTH_REJECTED_MAC,
|
||||
];
|
||||
|
||||
/**
|
||||
* Reset only the MACs this file owns, in a single request. Mock state is
|
||||
* per-MAC and sibling spec files talk to the same server from parallel
|
||||
* workers, so a global reset here would wipe their state mid-test — and
|
||||
* theirs would wipe ours.
|
||||
*/
|
||||
async function resetMockServer(request: APIRequestContext): Promise<void> {
|
||||
const query = OWNED_MACS.map(
|
||||
(mac) => `macAddress=${encodeURIComponent(mac)}`
|
||||
).join('&');
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const response = await request.post(`${MOCK_SERVER}/reset`);
|
||||
const response = await request.post(`${MOCK_SERVER}/reset?${query}`);
|
||||
if (response.ok()) {
|
||||
return;
|
||||
}
|
||||
@@ -130,6 +188,89 @@ async function addStalkerPortal(
|
||||
await page.waitForURL(/stalker.*vod/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Stalker portal through the canonical Ministra URL, which the app
|
||||
* imports as a FULL portal: handshake, Bearer token and watchdog.
|
||||
*/
|
||||
async function addFullStalkerPortal(
|
||||
page: Page,
|
||||
options: { name?: string; mac: string; expectContent?: boolean }
|
||||
): Promise<void> {
|
||||
const { name = 'Full Stalker Portal', mac, expectContent = true } = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
|
||||
|
||||
await setInputValue(dialog.locator('input#title'), name);
|
||||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||||
await setInputValue(dialog.locator('input#macAddress'), mac);
|
||||
|
||||
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
if (expectContent) {
|
||||
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Portal actions the app sent, in order, with the token each carried. */
|
||||
function recordPortalActions(page: Page): {
|
||||
actions: string[];
|
||||
tokensByAction: Map<string, string | null>;
|
||||
} {
|
||||
const actions: string[] = [];
|
||||
const tokensByAction = new Map<string, string | null>();
|
||||
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (!url.pathname.endsWith('/stalker')) {
|
||||
return;
|
||||
}
|
||||
const action = url.searchParams.get('action');
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
actions.push(action);
|
||||
if (!tokensByAction.has(action)) {
|
||||
tokensByAction.set(action, url.searchParams.get('token'));
|
||||
}
|
||||
});
|
||||
|
||||
return { actions, tokensByAction };
|
||||
}
|
||||
|
||||
const CONTENT_ACTIONS = [
|
||||
'get_categories',
|
||||
'get_genres',
|
||||
'get_ordered_list',
|
||||
'get_all_channels',
|
||||
];
|
||||
|
||||
/** Every portal request in order, with the token it carried. */
|
||||
function recordPortalRequests(
|
||||
page: Page
|
||||
): Array<{ action: string; token: string | null }> {
|
||||
const requests: Array<{ action: string; token: string | null }> = [];
|
||||
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (!url.pathname.endsWith('/stalker')) {
|
||||
return;
|
||||
}
|
||||
const action = url.searchParams.get('action');
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
requests.push({ action, token: url.searchParams.get('token') });
|
||||
});
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test setup
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -168,6 +309,26 @@ test('@stalker add a Stalker portal and see it in the playlist list', async ({
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('@stalker account info dialog shows subscription facts from the portal', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
// Open the header playlist switcher and its "Account info" entry for
|
||||
// the active stalker portal.
|
||||
await page.locator('.playlist-switcher-trigger').click();
|
||||
await page
|
||||
.locator('.context-action-item', { hasText: 'Account info' })
|
||||
.click();
|
||||
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText('Account Information')).toBeVisible();
|
||||
// Facts served by the mock's account_info/get_main_info handler.
|
||||
await expect(dialog.getByText('Mock Premium 180').first()).toBeVisible();
|
||||
await expect(dialog.getByText(DEFAULT_MAC).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('@stalker VOD — categories load from mock server', async ({ page }) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
@@ -517,9 +678,12 @@ test('@stalker mock server reset clears cached state', async ({ request }) => {
|
||||
);
|
||||
expect(before.ok()).toBeTruthy();
|
||||
|
||||
// Reset
|
||||
const reset = await request.post(`${MOCK_SERVER}/reset`);
|
||||
// Scoped reset — a global one would clear MACs owned by parallel specs.
|
||||
const reset = await request.post(
|
||||
`${MOCK_SERVER}/reset?macAddress=${encodeURIComponent(DEFAULT_MAC)}`
|
||||
);
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
expect((await reset.json()).macs).toEqual([DEFAULT_MAC]);
|
||||
|
||||
// Data is regenerated identically (deterministic seed)
|
||||
const after = await request.get(
|
||||
@@ -702,3 +866,193 @@ test('@stalker series — seasons load for a series item', async ({
|
||||
// Default scenario has 8 episodes per season
|
||||
expect(seasons[0].series.length).toBe(8);
|
||||
});
|
||||
|
||||
/**
|
||||
* Full-portal authentication. The tests above import through the tolerant
|
||||
* `/portal.php` alias (simple portal, no auth); these use the canonical
|
||||
* Ministra endpoint, which the mock guards like the real middleware:
|
||||
*
|
||||
* - every action except handshake/get_profile/get_localization/do_auth needs
|
||||
* `Authorization: Bearer <token>`
|
||||
* - a token only counts once `get_profile` has adopted it
|
||||
* - auth failures come back as HTTP 200 with a plain-text body, never a 401
|
||||
*/
|
||||
test.describe('@stalker full portal authentication', () => {
|
||||
// Importing a full portal costs a handshake, a profile call and the first
|
||||
// content load — on a cold dev server that alone approaches Playwright's
|
||||
// 30s default budget.
|
||||
test.beforeEach(() => {
|
||||
test.setTimeout(90_000);
|
||||
});
|
||||
|
||||
test('handshakes and authenticates before loading content', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { actions, tokensByAction } = recordPortalActions(page);
|
||||
|
||||
await addFullStalkerPortal(page, { mac: AUTH_FLOW_MAC });
|
||||
|
||||
// The portal only answers content actions for an adopted token, so
|
||||
// reaching the VOD categories at all proves the whole chain ran.
|
||||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(actions).toContain('handshake');
|
||||
expect(actions).toContain('get_profile');
|
||||
expect(actions.indexOf('handshake')).toBeLessThan(
|
||||
actions.indexOf('get_profile')
|
||||
);
|
||||
|
||||
const contentAction = actions.find((action) =>
|
||||
['get_categories', 'get_genres'].includes(action)
|
||||
);
|
||||
expect(contentAction).toBeDefined();
|
||||
expect(actions.indexOf('get_profile')).toBeLessThan(
|
||||
actions.indexOf(contentAction as string)
|
||||
);
|
||||
|
||||
// Content requests must carry the token; the handshake must not.
|
||||
expect(tokensByAction.get('handshake')).toBeFalsy();
|
||||
expect(tokensByAction.get(contentAction as string)).toBeTruthy();
|
||||
|
||||
// The full-portal workflow must also keep the watchdog alive — an
|
||||
// authenticated get_events fires immediately (init=1) on activation.
|
||||
// Without this assertion the suite would stay green if the watchdog
|
||||
// wiring silently died, because its failures are swallowed by design.
|
||||
await expect
|
||||
.poll(() => actions.includes('get_events'), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
expect(tokensByAction.get('get_events')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('never surfaces the portal plain-text auth failure as content', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const requests = recordPortalRequests(page);
|
||||
|
||||
// A MAC outside the Infomir OUI makes the strict endpoint answer
|
||||
// get_profile with a bare {status:1}, so no token is ever adopted and
|
||||
// every content request keeps returning the plain-text failure. Unlike
|
||||
// an invalidated session this cannot be repaired by the app's retry,
|
||||
// which is what makes the negative assertion below meaningful instead
|
||||
// of vacuous.
|
||||
const failureBody = await (
|
||||
await request.get(
|
||||
`${BACKEND_PROXY}?url=${encodeURIComponent(
|
||||
FULL_PORTAL_URL
|
||||
)}&macAddress=${encodeURIComponent(
|
||||
AUTH_REJECTED_MAC
|
||||
)}&action=get_categories&type=vod`
|
||||
)
|
||||
).json();
|
||||
expect(failureBody.payload).toBe('Authorization failed.');
|
||||
|
||||
await addFullStalkerPortal(page, {
|
||||
mac: AUTH_REJECTED_MAC,
|
||||
expectContent: false,
|
||||
});
|
||||
|
||||
// The app must have actually hit the failing portal...
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
requests.filter((entry) =>
|
||||
CONTENT_ACTIONS.includes(entry.action)
|
||||
).length,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// ...and must never render the raw portal response as content. A
|
||||
// portal answers auth failures with HTTP 200 + plain text, so an app
|
||||
// that trusts the status code would happily paint these strings.
|
||||
await expect(page.locator('body')).not.toContainText(
|
||||
'Authorization failed.'
|
||||
);
|
||||
await expect(page.locator('body')).not.toContainText(
|
||||
'Unauthorized request.'
|
||||
);
|
||||
});
|
||||
|
||||
test('re-authenticates after the portal drops the session', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const requests = recordPortalRequests(page);
|
||||
|
||||
await addFullStalkerPortal(page, { mac: AUTH_REAUTH_MAC });
|
||||
|
||||
// `addFullStalkerPortal` only awaits the route change, and on a slow
|
||||
// runner the first authenticated content request has not necessarily
|
||||
// gone out by then — poll for it instead of reading the log once.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
requests.some(
|
||||
(entry) =>
|
||||
CONTENT_ACTIONS.includes(entry.action) &&
|
||||
entry.token
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// The token the initial import authenticated with — recovery must end
|
||||
// up on a DIFFERENT one, or nothing was actually re-negotiated.
|
||||
const tokenBeforeInvalidation = requests.find(
|
||||
(entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token
|
||||
)?.token;
|
||||
expect(tokenBeforeInvalidation).toBeTruthy();
|
||||
|
||||
// Server-side session loss is what a real expired/replaced token looks
|
||||
// like: the next request gets "Authorization failed." with HTTP 200.
|
||||
const invalidated = await request.post(
|
||||
`${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent(
|
||||
AUTH_REAUTH_MAC
|
||||
)}`
|
||||
);
|
||||
expect(invalidated.ok()).toBe(true);
|
||||
|
||||
const requestCountBeforeNavigation = requests.length;
|
||||
|
||||
// Navigating to another content type forces a fresh portal request.
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
|
||||
// Recovery is only proven end to end when a CONTENT request goes out
|
||||
// under a freshly negotiated token — a re-handshake alone could still
|
||||
// leave the original request unreplayed or unauthorized. The mock only
|
||||
// answers content for an adopted token, so this doubles as proof the
|
||||
// new token was adopted via get_profile.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
requests
|
||||
.slice(requestCountBeforeNavigation)
|
||||
.filter(
|
||||
(entry) =>
|
||||
CONTENT_ACTIONS.includes(entry.action) &&
|
||||
entry.token &&
|
||||
entry.token !== tokenBeforeInvalidation
|
||||
).length,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const recovered = requests.slice(requestCountBeforeNavigation);
|
||||
expect(
|
||||
recovered.filter((entry) => entry.action === 'handshake').length
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// And the recovered session must actually render: the ITV categories
|
||||
// can only come from an authorized request against the new token.
|
||||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await expect(page.locator('body')).not.toContainText(
|
||||
'Authorization failed.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
EpgRuntimeBridgeService,
|
||||
EpgService,
|
||||
} from '@iptvnator/epg/data-access';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
WorkspaceShellContextDrawerService,
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { MockProvider } from 'ng-mocks';
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
@@ -114,6 +117,12 @@ describe('AppComponent', () => {
|
||||
MockProvider(Router, {
|
||||
navigateByUrl: jest.fn(),
|
||||
}),
|
||||
{
|
||||
// Root-provided in production; stubbed because the spec's
|
||||
// Router mock has no `events` stream for the real service.
|
||||
provide: WorkspaceShellContextDrawerService,
|
||||
useValue: { isOpen: () => false },
|
||||
},
|
||||
MockProvider(MatSnackBar, {
|
||||
open: jest.fn(),
|
||||
}),
|
||||
@@ -129,6 +138,7 @@ describe('AppComponent', () => {
|
||||
openGlobalRecent: jest.fn(),
|
||||
openGlobalSearch: jest.fn(),
|
||||
openAccountInfo: jest.fn(),
|
||||
openStalkerAccountInfo: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
EpgRuntimeBridgeService,
|
||||
EpgService,
|
||||
} from '@iptvnator/epg/data-access';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
WorkspaceShellContextDrawerService,
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel';
|
||||
import { WindowControlsComponent } from '@iptvnator/ui/components';
|
||||
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
|
||||
@@ -62,6 +65,7 @@ export class AppComponent implements OnInit {
|
||||
private playlistOpenRequests = inject(PlaylistOpenRequestService);
|
||||
private runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService);
|
||||
|
||||
/** Default language as fallback */
|
||||
private readonly DEFAULT_LANG = Language.ENGLISH;
|
||||
@@ -87,6 +91,12 @@ export class AppComponent implements OnInit {
|
||||
if (this.runtime.isElectron) {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// While the phone context drawer is modal, workspace
|
||||
// shortcuts must not navigate away behind it — same gate
|
||||
// as Ctrl/Cmd+F, Ctrl/Cmd+K, and the shortcuts dialog.
|
||||
if (this.contextDrawer.isOpen()) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'r') {
|
||||
event.preventDefault();
|
||||
this.workspaceShellActions.openGlobalRecent();
|
||||
|
||||
@@ -77,4 +77,47 @@ describe('PwaService', () => {
|
||||
|
||||
expect(http.match(() => true)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sends Stalker credentials as /stalker control params, including the serial', async () => {
|
||||
// JSDOM ships no fetch; install one for the proxy call.
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ payload: { js: [] } }),
|
||||
} as unknown as Response);
|
||||
const globalWithFetch = globalThis as { fetch?: typeof fetch };
|
||||
globalWithFetch.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
const request = service.forwardStalkerRequest({
|
||||
url: 'http://portal.example/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
token: 'TOKEN123',
|
||||
serialNumber: 'SN1234',
|
||||
params: { type: 'itv', action: 'get_ordered_list' },
|
||||
});
|
||||
|
||||
http.expectOne((req) =>
|
||||
req.url.endsWith('/provider-targets')
|
||||
).flush({ targetId: 'target-1' });
|
||||
|
||||
await expect(request).resolves.toEqual({ js: [] });
|
||||
|
||||
const requestUrl = new URL(String(fetchMock.mock.calls[0][0]));
|
||||
// The proxy turns these into the portal-facing
|
||||
// Cookie/Authorization/SN headers; they must all reach it so the
|
||||
// PWA transport can match the Electron one.
|
||||
expect(requestUrl.searchParams.get('macAddress')).toBe(
|
||||
'00:1A:79:AA:BB:CC'
|
||||
);
|
||||
expect(requestUrl.searchParams.get('token')).toBe('TOKEN123');
|
||||
expect(requestUrl.searchParams.get('serialNumber')).toBe(
|
||||
'SN1234'
|
||||
);
|
||||
expect(requestUrl.searchParams.get('action')).toBe(
|
||||
'get_ordered_list'
|
||||
);
|
||||
} finally {
|
||||
delete globalWithFetch.fetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,7 @@ export class PwaService extends DataService {
|
||||
macAddress: string;
|
||||
params: Record<string, string>;
|
||||
token?: string;
|
||||
serialNumber?: string;
|
||||
}
|
||||
) as T;
|
||||
}
|
||||
@@ -479,6 +480,7 @@ export class PwaService extends DataService {
|
||||
params: Record<string, string>;
|
||||
macAddress: string;
|
||||
token?: string;
|
||||
serialNumber?: string;
|
||||
}) {
|
||||
let context = createPortalDebugRequestContext({
|
||||
provider: 'stalker',
|
||||
@@ -494,11 +496,18 @@ export class PwaService extends DataService {
|
||||
try {
|
||||
const targetId = await this.getProviderTargetId(payload.url);
|
||||
const token = payload.token ?? payload.params.token;
|
||||
// `macAddress`, `token` and `serialNumber` are control params for
|
||||
// the /stalker proxy: it turns them into the portal-facing
|
||||
// Cookie / Authorization / SN headers and strips them from the
|
||||
// query it forwards to the portal.
|
||||
const requestParams = {
|
||||
targetId,
|
||||
...payload.params,
|
||||
macAddress: payload.macAddress,
|
||||
...(token ? { token } : {}),
|
||||
...(payload.serialNumber
|
||||
? { serialNumber: payload.serialNumber }
|
||||
: {}),
|
||||
};
|
||||
const params = new URLSearchParams(requestParams);
|
||||
const requestUrl = `${this.corsProxyUrl}/stalker?${params.toString()}`;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
WorkspaceAccountInfoData,
|
||||
WorkspacePlaylistType,
|
||||
WorkspaceShellActions,
|
||||
WorkspaceStalkerAccountInfoData,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -54,6 +55,19 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
openStalkerAccountInfo(data: WorkspaceStalkerAccountInfoData): void {
|
||||
void import('@iptvnator/portal/stalker/feature').then(
|
||||
({ StalkerAccountInfoComponent }) => {
|
||||
this.dialog.open(StalkerAccountInfoComponent, {
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
data,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function provideWorkspaceShellActions(): Provider[] {
|
||||
|
||||
@@ -751,6 +751,9 @@
|
||||
);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
// Stretched to the full layout width, so its own inline padding would be
|
||||
// added on top and push the settings page into a horizontal scroll.
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
|
||||
"NOT_YET_SYNCED": "لم تتم المزامنة بعد",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "ينتهي خلال {{days}} يوم",
|
||||
"SOURCE_EXPIRED": "منتهي"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "القنوات المحمّلة فقط",
|
||||
"COMMAND_PALETTE_SHORTCUT": "لوحة الأوامر (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "فتح لوحة الأوامر",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "تبديل لوحة الفئات",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "الفئات",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "تبديل لوحة التصفية",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "التصفية",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "تبديل لوحة أقسام الإعدادات",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "أقسام الإعدادات",
|
||||
"ADD_PLAYLIST_ALL": "إضافة قائمة تشغيل",
|
||||
"ADD_PLAYLIST": "إضافة قائمة تشغيل",
|
||||
"OPEN_DOWNLOADS": "فتح التنزيلات",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "مكتبة محلية",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "بانتظار استجابة المزود.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "تحضير تبديل أسرع لقائمة التشغيل في الزيارة القادمة.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "جارٍ قراءة المكتبة المحلية — لا يتم تنزيل أي بيانات من المزود.",
|
||||
"XTREAM_IMPORT_PREPARING": "جارٍ تحضير المكتبة المحلية...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "جارٍ تحميل الكتالوج المحفوظ من هذا الجهاز...",
|
||||
"XTREAM_IMPORT_LOADING": "جارٍ جلب بيانات قائمة التشغيل من المصدر...",
|
||||
"XTREAM_IMPORT_SAVING": "جارٍ حفظ بيانات قائمة التشغيل محليًا...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "جارٍ استعادة المفضلة من المكتبة المحلية...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "الانتقال إلى قسم المسلسلات",
|
||||
"SWITCH_PLAYER_LABEL": "تبديل المشغل إلى {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "تعيين {{name}} كمشغل الفيديو النشط",
|
||||
"SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "جارٍ تحديث قائمة التشغيل",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "جارٍ حفظ بيانات مكتبتك...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "جارٍ التحميل",
|
||||
"RETRY": "إعادة المحاولة",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "معلومات الحساب",
|
||||
"ACCOUNT": "الحساب",
|
||||
"PORTAL_INFO": "البوابة",
|
||||
"LOGIN": "اسم المستخدم",
|
||||
"STATUS": "الحالة",
|
||||
"ACTIVE": "نشط",
|
||||
"INACTIVE": "غير نشط",
|
||||
"TARIFF_PLAN": "خطة الاشتراك",
|
||||
"EXPIRES": "ينتهي في",
|
||||
"DAYS_LEFT": "الأيام المتبقية",
|
||||
"EXPIRED": "منتهي",
|
||||
"MAC_ADDRESS": "عنوان MAC",
|
||||
"PHONE": "الهاتف",
|
||||
"PORTAL_URL": "رابط البوابة",
|
||||
"PORTAL_TYPE_FULL": "بوابة Ministra",
|
||||
"PORTAL_TYPE_LEGACY": "بوابة تقليدية (portal.php)",
|
||||
"CACHED_BADGE": "بيانات محفوظة",
|
||||
"REFRESH_FAILED": "تعذّر الوصول إلى البوابة — يتم عرض البيانات المحفوظة عند إضافة البوابة.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "لا توجد بيانات للحساب",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "لا تنشر هذه البوابة معلومات الحساب لهذا الجهاز."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
|
||||
"NOT_YET_SYNCED": "مازال ما تزامنش",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "كتسالي ف {{days}} يوم",
|
||||
"SOURCE_EXPIRED": "سالات"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "غير القنوات المشرّجة",
|
||||
"COMMAND_PALETTE_SHORTCUT": "لوحة الأوامر (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "حل لوحة الأوامر",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "بدل لوحة الفئات",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "الفئات",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "بدل لوحة الفلاتر",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "الفلاتر",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "بدل لوحة أقسام الإعدادات",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "أقسام الإعدادات",
|
||||
"ADD_PLAYLIST_ALL": "زيد قائمة تشغيل",
|
||||
"ADD_PLAYLIST": "زيد قائمة تشغيل",
|
||||
"OPEN_DOWNLOADS": "حل التحميلات",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "المكتبة المحلية",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "كنتسناو رد المزود.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "كنوجدو تبديل أسرع لقوائم التشغيل للزيارة الجاية.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "جاري قراءة المكتبة المحلية — ما شي بيانات كتتنزلو من المزود.",
|
||||
"XTREAM_IMPORT_PREPARING": "جاري تحضير المكتبة المحلية...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "جاري تشريج الفهرس المحفوظ من الجهاز ديالك...",
|
||||
"XTREAM_IMPORT_LOADING": "جاري جلب بيانات قائمة التشغيل من المصدر...",
|
||||
"XTREAM_IMPORT_SAVING": "جاري حفظ بيانات قائمة التشغيل محلياً...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "جاري استرجاع المفضلة من المكتبة المحلية...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "اقفز لقسم السلاسل",
|
||||
"SWITCH_PLAYER_LABEL": "بدل المشغل ل{{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "حدد {{name}} كمشغل الفيديو النشط",
|
||||
"SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "كنحدّثو البلايليست",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "كنحافظو على معطيات المكتبة ديالك...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "كيتشارجا",
|
||||
"RETRY": "عاود المحاولة",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "معلومات الحساب",
|
||||
"ACCOUNT": "الحساب",
|
||||
"PORTAL_INFO": "البوابة",
|
||||
"LOGIN": "سمية المستخدم",
|
||||
"STATUS": "الحالة",
|
||||
"ACTIVE": "خدام",
|
||||
"INACTIVE": "ماشي خدام",
|
||||
"TARIFF_PLAN": "خطة الاشتراك",
|
||||
"EXPIRES": "كتسالي ف",
|
||||
"DAYS_LEFT": "الأيام اللي بقاو",
|
||||
"EXPIRED": "سالات",
|
||||
"MAC_ADDRESS": "عنوان MAC",
|
||||
"PHONE": "التيليفون",
|
||||
"PORTAL_URL": "رابط البوابة",
|
||||
"PORTAL_TYPE_FULL": "بوابة Ministra",
|
||||
"PORTAL_TYPE_LEGACY": "بوابة قديمة (portal.php)",
|
||||
"CACHED_BADGE": "بيانات محفوظة",
|
||||
"REFRESH_FAILED": "ما قدرناش نوصلو للبوابة — كنعرضو البيانات اللي تسجلات ملي تزادت البوابة.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "ما كايناش بيانات الحساب",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "هاد البوابة ما كتعطيش معلومات الحساب لهاد الجهاز."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
|
||||
"NOT_YET_SYNCED": "Яшчэ не сінхранізавана",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Скончыцца праз {{days}} дз.",
|
||||
"SOURCE_EXPIRED": "Скончыўся"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Толькі загружаныя каналы",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Палітра каманд (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Адкрыць палітру каманд",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Паказаць або схаваць панэль катэгорый",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Катэгорыі",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Паказаць або схаваць панэль фільтраў",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Фільтры",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Паказаць або схаваць панэль раздзелаў налад",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Раздзелы налад",
|
||||
"ADD_PLAYLIST_ALL": "Дадаць плэйліст",
|
||||
"ADD_PLAYLIST": "Дадаць плэйліст",
|
||||
"OPEN_DOWNLOADS": "Адкрыць спампаванні",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Лакальная бібліятэка",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Чаканне адказу ад правайдэра.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Падрыхтоўка хуткага пераключэння плэйлістоў для наступнага візіту.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Чытанне лакальнай бібліятэкі — даныя не загружаюцца ад правайдэра.",
|
||||
"XTREAM_IMPORT_PREPARING": "Падрыхтоўка лакальнай бібліятэкі...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Загрузка захаванага каталога з гэтай прылады...",
|
||||
"XTREAM_IMPORT_LOADING": "Атрыманне дадзеных плэйліста з крыніцы...",
|
||||
"XTREAM_IMPORT_SAVING": "Захаванне дадзеных плэйліста лакальна...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Аднаўленне выбранага з лакальнай бібліятэкі...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Перайсці да раздзела серыялаў",
|
||||
"SWITCH_PLAYER_LABEL": "Пераключыць прайгравальнік на {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Усталяваць {{name}} як актыўны відэапрайгравальнік",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Прайгравальнік усталяваны: {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Прайгравальнік усталяваны: {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Паказаць даныя акаўнта Stalker-партала"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Абнаўленне плэйліста",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Захаванне даных вашай бібліятэкі...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Загрузка",
|
||||
"RETRY": "Паўтарыць",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Інфармацыя пра акаўнт",
|
||||
"ACCOUNT": "Акаўнт",
|
||||
"PORTAL_INFO": "Партал",
|
||||
"LOGIN": "Лагін",
|
||||
"STATUS": "Статус",
|
||||
"ACTIVE": "Актыўны",
|
||||
"INACTIVE": "Неактыўны",
|
||||
"TARIFF_PLAN": "Тарыфны план",
|
||||
"EXPIRES": "Сканчаецца",
|
||||
"DAYS_LEFT": "Засталося дзён",
|
||||
"EXPIRED": "Скончыўся",
|
||||
"MAC_ADDRESS": "MAC-адрас",
|
||||
"PHONE": "Тэлефон",
|
||||
"PORTAL_URL": "URL партала",
|
||||
"PORTAL_TYPE_FULL": "Партал Ministra",
|
||||
"PORTAL_TYPE_LEGACY": "Класічны партал (portal.php)",
|
||||
"CACHED_BADGE": "Захаваныя даныя",
|
||||
"REFRESH_FAILED": "Партал недаступны — паказаны даныя, захаваныя пры даданні партала.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Няма даных пра акаўнт",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Гэты партал не дае інфармацыі пра акаўнт для гэтай прылады."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "St{{season}} · F{{episode}}",
|
||||
"NOT_YET_SYNCED": "Noch nicht synchronisiert",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Zuletzt angesehenes Live-TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorisierte Filme & Serien"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorisierte Filme & Serien",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Läuft in {{days}} T. ab",
|
||||
"SOURCE_EXPIRED": "Abgelaufen"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Nur geladene Sender",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Befehlspalette (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Befehlspalette öffnen",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Kategorienbereich ein- oder ausblenden",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Kategorien",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Filterbereich ein- oder ausblenden",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filter",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Einstellungsbereiche ein- oder ausblenden",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Einstellungsbereiche",
|
||||
"ADD_PLAYLIST_ALL": "Playlist hinzufügen (alle Playlists)",
|
||||
"ADD_PLAYLIST": "Playlist hinzufügen",
|
||||
"OPEN_DOWNLOADS": "Downloads öffnen",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Lokale Bibliothek",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Warte auf die Antwort des Providers.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Bereitet schnellere Playlist-Wechsel für den nächsten Besuch vor.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Lokaler Katalog wird gelesen – keine Daten vom Anbieter heruntergeladen",
|
||||
"XTREAM_IMPORT_PREPARING": "Lokale Bibliothek wird vorbereitet...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Gespeicherter Katalog wird von diesem Gerät geladen…",
|
||||
"XTREAM_IMPORT_LOADING": "Playlist-Daten werden von der Quelle geladen...",
|
||||
"XTREAM_IMPORT_SAVING": "Playlist-Daten werden lokal gespeichert...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Favoriten werden aus der lokalen Bibliothek wiederhergestellt...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Zum Serienbereich wechseln",
|
||||
"SWITCH_PLAYER_LABEL": "Player auf {{name}} wechseln",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "{{name}} als aktiven Videoplayer festlegen",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Player auf {{name}} festgelegt"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Player auf {{name}} festgelegt",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Kontodaten des Stalker-Portals anzeigen"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Playlist wird aktualisiert",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Mediathekdaten werden gesichert...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Laden",
|
||||
"RETRY": "Erneut versuchen",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Kontoinformationen",
|
||||
"ACCOUNT": "Konto",
|
||||
"PORTAL_INFO": "Portal",
|
||||
"LOGIN": "Login",
|
||||
"STATUS": "Status",
|
||||
"ACTIVE": "Aktiv",
|
||||
"INACTIVE": "Inaktiv",
|
||||
"TARIFF_PLAN": "Tarif",
|
||||
"EXPIRES": "Läuft ab",
|
||||
"DAYS_LEFT": "Verbleibende Tage",
|
||||
"EXPIRED": "Abgelaufen",
|
||||
"MAC_ADDRESS": "MAC-Adresse",
|
||||
"PHONE": "Telefon",
|
||||
"PORTAL_URL": "Portal-URL",
|
||||
"PORTAL_TYPE_FULL": "Ministra-Portal",
|
||||
"PORTAL_TYPE_LEGACY": "Klassisches Portal (portal.php)",
|
||||
"CACHED_BADGE": "Gespeicherte Daten",
|
||||
"REFRESH_FAILED": "Das Portal ist nicht erreichbar — es werden die beim Hinzufügen gespeicherten Daten angezeigt.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Keine Kontodaten",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Dieses Portal stellt für dieses Gerät keine Kontoinformationen bereit."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "Σ{{season}}·Ε{{episode}}",
|
||||
"NOT_YET_SYNCED": "Δεν έχει συγχρονιστεί ακόμα",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Λήγει σε {{days}} ημ.",
|
||||
"SOURCE_EXPIRED": "Έληξε"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Μόνο φορτωμένα κανάλια",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Παλέτα εντολών (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Άνοιγμα παλέτας εντολών",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Εναλλαγή του πίνακα κατηγοριών",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Κατηγορίες",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Εναλλαγή του πίνακα φίλτρων",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Φίλτρα",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Εναλλαγή του πίνακα ενοτήτων ρυθμίσεων",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Ενότητες ρυθμίσεων",
|
||||
"ADD_PLAYLIST_ALL": "Προσθήκη λίστας αναπαραγωγής",
|
||||
"ADD_PLAYLIST": "Προσθήκη λίστας αναπαραγωγής",
|
||||
"OPEN_DOWNLOADS": "Άνοιγμα λήψεων",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Τοπική βιβλιοθήκη",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Αναμονή απάντησης από τον πάροχο.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Προετοιμασία γρηγορότερης εναλλαγής λίστας αναπαραγωγής για την επόμενη επίσκεψη.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Ανάγνωση της τοπικής βιβλιοθήκης — δεν γίνονται λήψεις δεδομένων από τον πάροχο.",
|
||||
"XTREAM_IMPORT_PREPARING": "Προετοιμασία τοπικής βιβλιοθήκης...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Φόρτωση του αποθηκευμένου καταλόγου από αυτήν τη συσκευή...",
|
||||
"XTREAM_IMPORT_LOADING": "Λήψη δεδομένων λίστας αναπαραγωγής από την πηγή...",
|
||||
"XTREAM_IMPORT_SAVING": "Αποθήκευση δεδομένων λίστας αναπαραγωγής τοπικά...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Επαναφορά αγαπημένων από την τοπική βιβλιοθήκη...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Μετάβαση στην ενότητα σειρών",
|
||||
"SWITCH_PLAYER_LABEL": "Εναλλαγή προγράμματος αναπαραγωγής σε {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Ορισμός του {{name}} ως ενεργό πρόγραμμα αναπαραγωγής βίντεο",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Εμφάνιση στοιχείων λογαριασμού της πύλης Stalker"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Ανανέωση λίστας αναπαραγωγής",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Διατήρηση των δεδομένων της βιβλιοθήκης σας...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Φόρτωση",
|
||||
"RETRY": "Επανάληψη",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Πληροφορίες λογαριασμού",
|
||||
"ACCOUNT": "Λογαριασμός",
|
||||
"PORTAL_INFO": "Πύλη",
|
||||
"LOGIN": "Όνομα χρήστη",
|
||||
"STATUS": "Κατάσταση",
|
||||
"ACTIVE": "Ενεργός",
|
||||
"INACTIVE": "Ανενεργός",
|
||||
"TARIFF_PLAN": "Πρόγραμμα χρέωσης",
|
||||
"EXPIRES": "Λήγει",
|
||||
"DAYS_LEFT": "Ημέρες που απομένουν",
|
||||
"EXPIRED": "Έληξε",
|
||||
"MAC_ADDRESS": "Διεύθυνση MAC",
|
||||
"PHONE": "Τηλέφωνο",
|
||||
"PORTAL_URL": "URL πύλης",
|
||||
"PORTAL_TYPE_FULL": "Πύλη Ministra",
|
||||
"PORTAL_TYPE_LEGACY": "Κλασική πύλη (portal.php)",
|
||||
"CACHED_BADGE": "Αποθηκευμένα δεδομένα",
|
||||
"REFRESH_FAILED": "Η πύλη δεν είναι προσβάσιμη — εμφανίζονται τα δεδομένα που αποθηκεύτηκαν κατά την προσθήκη της.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Δεν υπάρχουν στοιχεία λογαριασμού",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Αυτή η πύλη δεν δημοσιεύει πληροφορίες λογαριασμού για αυτήν τη συσκευή."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Not yet synced",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expires in {{days}} d",
|
||||
"SOURCE_EXPIRED": "Expired"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Loaded channels only",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Command palette (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Open command palette",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Toggle the categories panel",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Categories",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Toggle the filters panel",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filters",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Toggle the settings sections panel",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Settings sections",
|
||||
"ADD_PLAYLIST_ALL": "Add playlist",
|
||||
"ADD_PLAYLIST": "Add playlist",
|
||||
"OPEN_DOWNLOADS": "Open downloads",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Local library",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Waiting on the provider response.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparing faster playlist switching for the next visit.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Reading the local library — no data is downloaded from the provider.",
|
||||
"XTREAM_IMPORT_PREPARING": "Preparing local library...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Loading the saved catalog from this device...",
|
||||
"XTREAM_IMPORT_LOADING": "Fetching playlist data from source...",
|
||||
"XTREAM_IMPORT_SAVING": "Saving playlist data locally...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restoring favorites from local library...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Jump to series section",
|
||||
"SWITCH_PLAYER_LABEL": "Switch player to {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Set {{name}} as the active video player",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Show Stalker portal account details"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Refreshing playlist",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Preserving your library data...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Loading",
|
||||
"RETRY": "Retry",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Account Information",
|
||||
"ACCOUNT": "Account",
|
||||
"PORTAL_INFO": "Portal",
|
||||
"LOGIN": "Login",
|
||||
"STATUS": "Status",
|
||||
"ACTIVE": "Active",
|
||||
"INACTIVE": "Inactive",
|
||||
"TARIFF_PLAN": "Tariff plan",
|
||||
"EXPIRES": "Expires",
|
||||
"DAYS_LEFT": "Days left",
|
||||
"EXPIRED": "Expired",
|
||||
"MAC_ADDRESS": "MAC address",
|
||||
"PHONE": "Phone",
|
||||
"PORTAL_URL": "Portal URL",
|
||||
"PORTAL_TYPE_FULL": "Ministra portal",
|
||||
"PORTAL_TYPE_LEGACY": "Legacy portal (portal.php)",
|
||||
"CACHED_BADGE": "Saved data",
|
||||
"REFRESH_FAILED": "The portal could not be reached — showing the data saved when the portal was added.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "No account details",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "This portal does not publish account information for this device."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Aún no sincronizado",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Caduca en {{days}} d",
|
||||
"SOURCE_EXPIRED": "Caducada"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Solo canales cargados",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Paleta de comandos (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Abrir paleta de comandos",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Mostrar u ocultar el panel de categorías",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Categorías",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Mostrar u ocultar el panel de filtros",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtros",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Mostrar u ocultar el panel de secciones de ajustes",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Secciones de ajustes",
|
||||
"ADD_PLAYLIST_ALL": "Agregar lista de reproducción",
|
||||
"ADD_PLAYLIST": "Agregar lista de reproducción",
|
||||
"OPEN_DOWNLOADS": "Abrir descargas",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Biblioteca local",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Esperando la respuesta del proveedor.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparando un cambio de lista más rápido para la próxima visita.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Leyendo la biblioteca local — sin descargar datos del proveedor.",
|
||||
"XTREAM_IMPORT_PREPARING": "Preparando biblioteca local…",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Cargando el catálogo guardado desde este dispositivo...",
|
||||
"XTREAM_IMPORT_LOADING": "Obteniendo datos de la lista desde la fuente…",
|
||||
"XTREAM_IMPORT_SAVING": "Guardando datos de la lista localmente…",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restaurando favoritos desde la biblioteca local…",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Ir a la sección de series",
|
||||
"SWITCH_PLAYER_LABEL": "Cambiar reproductor a {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Establecer {{name}} como reproductor de vídeo activo",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Reproductor establecido a {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Reproductor establecido a {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar los datos de la cuenta del portal Stalker"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Actualizando lista de reproducción",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Conservando los datos de tu biblioteca...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Cargando",
|
||||
"RETRY": "Reintentar",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Información de la cuenta",
|
||||
"ACCOUNT": "Cuenta",
|
||||
"PORTAL_INFO": "Portal",
|
||||
"LOGIN": "Usuario",
|
||||
"STATUS": "Estado",
|
||||
"ACTIVE": "Activa",
|
||||
"INACTIVE": "Inactiva",
|
||||
"TARIFF_PLAN": "Plan de tarifa",
|
||||
"EXPIRES": "Caduca",
|
||||
"DAYS_LEFT": "Días restantes",
|
||||
"EXPIRED": "Caducada",
|
||||
"MAC_ADDRESS": "Dirección MAC",
|
||||
"PHONE": "Teléfono",
|
||||
"PORTAL_URL": "URL del portal",
|
||||
"PORTAL_TYPE_FULL": "Portal Ministra",
|
||||
"PORTAL_TYPE_LEGACY": "Portal clásico (portal.php)",
|
||||
"CACHED_BADGE": "Datos guardados",
|
||||
"REFRESH_FAILED": "No se pudo conectar con el portal — se muestran los datos guardados al añadirlo.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Sin datos de la cuenta",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Este portal no publica información de la cuenta para este dispositivo."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Pas encore synchronisé",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expire dans {{days}} j",
|
||||
"SOURCE_EXPIRED": "Expiré"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Chaînes chargées uniquement",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Palette de commandes (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Ouvrir la palette de commandes",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Afficher ou masquer le panneau des catégories",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Catégories",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Afficher ou masquer le panneau des filtres",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtres",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Afficher ou masquer le panneau des sections des paramètres",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Sections des paramètres",
|
||||
"ADD_PLAYLIST_ALL": "Ajouter une liste de lecture",
|
||||
"ADD_PLAYLIST": "Ajouter une liste de lecture",
|
||||
"OPEN_DOWNLOADS": "Ouvrir les téléchargements",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Bibliothèque locale",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "En attente de la réponse du fournisseur.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Préparation d'une bascule plus rapide entre listes de lecture pour la prochaine visite.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Lecture depuis la bibliothèque locale — aucune donnée n'est téléchargée depuis le fournisseur.",
|
||||
"XTREAM_IMPORT_PREPARING": "Préparation de la bibliothèque locale…",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Chargement du catalogue sauvegardé de cet appareil…",
|
||||
"XTREAM_IMPORT_LOADING": "Récupération des données de la liste depuis la source…",
|
||||
"XTREAM_IMPORT_SAVING": "Enregistrement des données de la liste localement…",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restauration des favoris depuis la bibliothèque locale…",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Aller à la section séries",
|
||||
"SWITCH_PLAYER_LABEL": "Basculer le lecteur sur {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Définir {{name}} comme lecteur vidéo actif",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Lecteur défini sur {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Lecteur défini sur {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Afficher les informations du compte du portail Stalker"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Actualisation de la playlist",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Préservation des données de votre bibliothèque...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Chargement",
|
||||
"RETRY": "Réessayer",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Informations du compte",
|
||||
"ACCOUNT": "Compte",
|
||||
"PORTAL_INFO": "Portail",
|
||||
"LOGIN": "Identifiant",
|
||||
"STATUS": "Statut",
|
||||
"ACTIVE": "Actif",
|
||||
"INACTIVE": "Inactif",
|
||||
"TARIFF_PLAN": "Forfait",
|
||||
"EXPIRES": "Expire",
|
||||
"DAYS_LEFT": "Jours restants",
|
||||
"EXPIRED": "Expiré",
|
||||
"MAC_ADDRESS": "Adresse MAC",
|
||||
"PHONE": "Téléphone",
|
||||
"PORTAL_URL": "URL du portail",
|
||||
"PORTAL_TYPE_FULL": "Portail Ministra",
|
||||
"PORTAL_TYPE_LEGACY": "Portail classique (portal.php)",
|
||||
"CACHED_BADGE": "Données enregistrées",
|
||||
"REFRESH_FAILED": "Le portail est injoignable — affichage des données enregistrées lors de son ajout.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Aucune donnée de compte",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Ce portail ne publie pas d’informations de compte pour cet appareil."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "É{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Még nincs szinkronizálva",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Legutóbb nézett élő csatornák",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Kedvenc filmek és sorozatok"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Kedvenc filmek és sorozatok",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} nap múlva lejár",
|
||||
"SOURCE_EXPIRED": "Lejárt"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Csak betöltött csatornák",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Parancspaletta (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Parancspaletta megnyitása",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Kategóriapanel megjelenítése vagy elrejtése",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Kategóriák",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Szűrőpanel megjelenítése vagy elrejtése",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Szűrők",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Beállítási szakaszok paneljének megjelenítése vagy elrejtése",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Beállítási szakaszok",
|
||||
"ADD_PLAYLIST_ALL": "Lejátszási lista hozzáadása",
|
||||
"ADD_PLAYLIST": "Lejátszási lista hozzáadása",
|
||||
"OPEN_DOWNLOADS": "Letöltések megnyitása",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Helyi médiatár",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Várakozás a szolgáltató válaszára.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "A lejátszási listák gyorsabb későbbi megnyitásának előkészítése.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "A helyi könyvtár beolvasása — a szolgáltatótól nem töltődnek le adatok.",
|
||||
"XTREAM_IMPORT_PREPARING": "Helyi médiatár előkészítése…",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Az eszközön mentett katalógus betöltése…",
|
||||
"XTREAM_IMPORT_LOADING": "Lejátszási lista adatainak lekérése a forrásból…",
|
||||
"XTREAM_IMPORT_SAVING": "Lejátszási lista adatainak helyi mentése…",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Kedvencek visszaállítása a helyi médiatárból…",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Ugrás a sorozatokhoz",
|
||||
"SWITCH_PLAYER_LABEL": "Lejátszóváltás: {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "{{name}} beállítása aktív videólejátszóként",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Aktív lejátszó: {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Aktív lejátszó: {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "A Stalker portál fiókadatainak megjelenítése"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Lejátszási lista frissítése",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "A médiatár adatainak megőrzése…",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Még nincs megtekintési előzmény",
|
||||
"NO_ITEMS_BODY": "Indítson el valamilyen tartalmat a médiatárból; ezután itt is megjelenik."
|
||||
}
|
||||
},
|
||||
"LOADING": "Betöltés",
|
||||
"RETRY": "Újra",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Fiókadatok",
|
||||
"ACCOUNT": "Fiók",
|
||||
"PORTAL_INFO": "Portál",
|
||||
"LOGIN": "Felhasználónév",
|
||||
"STATUS": "Állapot",
|
||||
"ACTIVE": "Aktív",
|
||||
"INACTIVE": "Inaktív",
|
||||
"TARIFF_PLAN": "Díjcsomag",
|
||||
"EXPIRES": "Lejárat",
|
||||
"DAYS_LEFT": "Hátralévő napok",
|
||||
"EXPIRED": "Lejárt",
|
||||
"MAC_ADDRESS": "MAC-cím",
|
||||
"PHONE": "Telefon",
|
||||
"PORTAL_URL": "Portál URL",
|
||||
"PORTAL_TYPE_FULL": "Ministra portál",
|
||||
"PORTAL_TYPE_LEGACY": "Klasszikus portál (portal.php)",
|
||||
"CACHED_BADGE": "Mentett adatok",
|
||||
"REFRESH_FAILED": "A portál nem érhető el — a portál hozzáadásakor mentett adatok láthatók.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Nincsenek fiókadatok",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Ez a portál nem tesz közzé fiókadatokat ehhez az eszközhöz."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Non ancora sincronizzato",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Scade tra {{days}} g",
|
||||
"SOURCE_EXPIRED": "Scaduto"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Solo canali caricati",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Tavolozza comandi (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Apri tavolozza comandi",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Mostra o nascondi il pannello delle categorie",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Categorie",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Mostra o nascondi il pannello dei filtri",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtri",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Mostra o nascondi il pannello delle sezioni delle impostazioni",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Sezioni delle impostazioni",
|
||||
"ADD_PLAYLIST_ALL": "Aggiungi playlist",
|
||||
"ADD_PLAYLIST": "Aggiungi playlist",
|
||||
"OPEN_DOWNLOADS": "Apri download",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Libreria locale",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "In attesa della risposta del fornitore.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparazione di un cambio playlist più rapido per la prossima visita.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Lettura della libreria locale — nessun dato viene scaricato dal fornitore.",
|
||||
"XTREAM_IMPORT_PREPARING": "Preparazione libreria locale...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Caricamento del catalogo salvato da questo dispositivo...",
|
||||
"XTREAM_IMPORT_LOADING": "Recupero dati playlist dalla sorgente...",
|
||||
"XTREAM_IMPORT_SAVING": "Salvataggio dati playlist in locale...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Ripristino preferiti dalla libreria locale...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Vai alla sezione serie",
|
||||
"SWITCH_PLAYER_LABEL": "Passa al lettore {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Imposta {{name}} come lettore video attivo",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostra i dati dell’account del portale Stalker"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Aggiornamento playlist",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Conservazione dei dati della libreria...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Caricamento",
|
||||
"RETRY": "Riprova",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Informazioni account",
|
||||
"ACCOUNT": "Account",
|
||||
"PORTAL_INFO": "Portale",
|
||||
"LOGIN": "Login",
|
||||
"STATUS": "Stato",
|
||||
"ACTIVE": "Attivo",
|
||||
"INACTIVE": "Inattivo",
|
||||
"TARIFF_PLAN": "Piano tariffario",
|
||||
"EXPIRES": "Scade",
|
||||
"DAYS_LEFT": "Giorni rimanenti",
|
||||
"EXPIRED": "Scaduto",
|
||||
"MAC_ADDRESS": "Indirizzo MAC",
|
||||
"PHONE": "Telefono",
|
||||
"PORTAL_URL": "URL del portale",
|
||||
"PORTAL_TYPE_FULL": "Portale Ministra",
|
||||
"PORTAL_TYPE_LEGACY": "Portale classico (portal.php)",
|
||||
"CACHED_BADGE": "Dati salvati",
|
||||
"REFRESH_FAILED": "Impossibile raggiungere il portale — vengono mostrati i dati salvati al momento dell’aggiunta.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Nessun dato account",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Questo portale non pubblica informazioni sull’account per questo dispositivo."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}・E{{episode}}",
|
||||
"NOT_YET_SYNCED": "未同期",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "あと{{days}}日で期限切れ",
|
||||
"SOURCE_EXPIRED": "期限切れ"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "読み込み済みチャンネルのみ",
|
||||
"COMMAND_PALETTE_SHORTCUT": "コマンドパレット(Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "コマンドパレットを開く",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "カテゴリーパネルを切り替え",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "カテゴリー",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "フィルターパネルを切り替え",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "フィルター",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "設定セクションパネルを切り替え",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "設定セクション",
|
||||
"ADD_PLAYLIST_ALL": "プレイリストを追加",
|
||||
"ADD_PLAYLIST": "プレイリストを追加",
|
||||
"OPEN_DOWNLOADS": "ダウンロードを開く",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "ローカルライブラリ",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "プロバイダーからの応答を待っています。",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "次回のためにプレイリスト切り替えを高速化する準備をしています。",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "ローカルキャッシュを読み込み中 — プロバイダーからはデータをダウンロードしていません。",
|
||||
"XTREAM_IMPORT_PREPARING": "ローカルライブラリを準備中...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "このデバイスに保存されたカタログを読み込み中...",
|
||||
"XTREAM_IMPORT_LOADING": "ソースからプレイリストデータを取得中...",
|
||||
"XTREAM_IMPORT_SAVING": "プレイリストデータをローカルに保存中...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "ローカルライブラリからお気に入りを復元中...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "シリーズセクションに移動",
|
||||
"SWITCH_PLAYER_LABEL": "プレーヤーを{{name}}に切り替え",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "{{name}}をアクティブなビデオプレーヤーに設定",
|
||||
"SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました"
|
||||
"SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalkerポータルのアカウント情報を表示"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "プレイリストを更新中",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "ライブラリデータを保持しています...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "読み込み中",
|
||||
"RETRY": "再試行",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "アカウント情報",
|
||||
"ACCOUNT": "アカウント",
|
||||
"PORTAL_INFO": "ポータル",
|
||||
"LOGIN": "ログイン",
|
||||
"STATUS": "ステータス",
|
||||
"ACTIVE": "有効",
|
||||
"INACTIVE": "無効",
|
||||
"TARIFF_PLAN": "料金プラン",
|
||||
"EXPIRES": "有効期限",
|
||||
"DAYS_LEFT": "残り日数",
|
||||
"EXPIRED": "期限切れ",
|
||||
"MAC_ADDRESS": "MACアドレス",
|
||||
"PHONE": "電話番号",
|
||||
"PORTAL_URL": "ポータルURL",
|
||||
"PORTAL_TYPE_FULL": "Ministraポータル",
|
||||
"PORTAL_TYPE_LEGACY": "レガシーポータル(portal.php)",
|
||||
"CACHED_BADGE": "保存済みデータ",
|
||||
"REFRESH_FAILED": "ポータルに接続できません — ポータル追加時に保存されたデータを表示しています。",
|
||||
"NO_ACCOUNT_DATA_TITLE": "アカウント情報なし",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "このポータルは、このデバイスのアカウント情報を公開していません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "시즌{{season}} {{episode}}화",
|
||||
"NOT_YET_SYNCED": "아직 동기화되지 않음",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}}일 후 만료",
|
||||
"SOURCE_EXPIRED": "만료됨"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "불러온 채널만",
|
||||
"COMMAND_PALETTE_SHORTCUT": "명령어 팔레트 (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "명령어 팔레트 열기",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "카테고리 패널 전환",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "카테고리",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "필터 패널 전환",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "필터",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "설정 섹션 패널 전환",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "설정 섹션",
|
||||
"ADD_PLAYLIST_ALL": "재생목록 추가",
|
||||
"ADD_PLAYLIST": "재생목록 추가",
|
||||
"OPEN_DOWNLOADS": "다운로드 열기",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "로컬 라이브러리",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "공급자 응답을 기다리는 중입니다.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "다음 방문 시 더 빠른 재생목록 전환을 위해 준비하는 중입니다.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "로컬 카탈로그를 읽는 중 — 공급자에서 데이터를 내려받지 않습니다.",
|
||||
"XTREAM_IMPORT_PREPARING": "로컬 라이브러리를 준비하는 중...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "이 기기에서 저장된 카탈로그를 불러오는 중...",
|
||||
"XTREAM_IMPORT_LOADING": "소스에서 재생목록 데이터를 가져오는 중...",
|
||||
"XTREAM_IMPORT_SAVING": "재생목록 데이터를 로컬에 저장하는 중...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "로컬 라이브러리에서 즐겨찾기를 복원하는 중...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "시리즈 섹션으로 이동",
|
||||
"SWITCH_PLAYER_LABEL": "플레이어를 {{name}}(으)로 전환",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "{{name}}을(를) 활성 비디오 플레이어로 설정",
|
||||
"SWITCH_PLAYER_FEEDBACK": "플레이어가 {{name}}(으)로 설정되었습니다"
|
||||
"SWITCH_PLAYER_FEEDBACK": "플레이어가 {{name}}(으)로 설정되었습니다",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker 포털 계정 정보 표시"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "재생목록 새로 고침 중",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "라이브러리 데이터를 보존하는 중...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "로드 중",
|
||||
"RETRY": "다시 시도",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "계정 정보",
|
||||
"ACCOUNT": "계정",
|
||||
"PORTAL_INFO": "포털",
|
||||
"LOGIN": "로그인",
|
||||
"STATUS": "상태",
|
||||
"ACTIVE": "활성",
|
||||
"INACTIVE": "비활성",
|
||||
"TARIFF_PLAN": "요금제",
|
||||
"EXPIRES": "만료일",
|
||||
"DAYS_LEFT": "남은 일수",
|
||||
"EXPIRED": "만료됨",
|
||||
"MAC_ADDRESS": "MAC 주소",
|
||||
"PHONE": "전화번호",
|
||||
"PORTAL_URL": "포털 URL",
|
||||
"PORTAL_TYPE_FULL": "Ministra 포털",
|
||||
"PORTAL_TYPE_LEGACY": "레거시 포털(portal.php)",
|
||||
"CACHED_BADGE": "저장된 데이터",
|
||||
"REFRESH_FAILED": "포털에 연결할 수 없습니다 — 포털 추가 시 저장된 데이터를 표시합니다.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "계정 정보 없음",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "이 포털은 이 기기에 대한 계정 정보를 제공하지 않습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·A{{episode}}",
|
||||
"NOT_YET_SYNCED": "Nog niet gesynchroniseerd",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Verloopt over {{days}} d",
|
||||
"SOURCE_EXPIRED": "Verlopen"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Alleen geladen kanalen",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Opdrachtenpalet (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Open opdrachtenpalet",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Categorieënpaneel tonen of verbergen",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Categorieën",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Filterpaneel tonen of verbergen",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filters",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Paneel met instellingensecties tonen of verbergen",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Instellingensecties",
|
||||
"ADD_PLAYLIST_ALL": "Afspeellijst toevoegen",
|
||||
"ADD_PLAYLIST": "Afspeellijst toevoegen",
|
||||
"OPEN_DOWNLOADS": "Open downloads",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Lokale bibliotheek",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Wachten op antwoord van de provider.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Sneller schakelen tussen afspeellijsten voorbereiden voor het volgende bezoek.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Lezen uit de lokale bibliotheek — er worden geen gegevens van de provider gedownload.",
|
||||
"XTREAM_IMPORT_PREPARING": "Lokale bibliotheek voorbereiden...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Gecachte catalogus van dit apparaat laden...",
|
||||
"XTREAM_IMPORT_LOADING": "Afspeellijstgegevens ophalen van de bron...",
|
||||
"XTREAM_IMPORT_SAVING": "Afspeellijstgegevens lokaal opslaan...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Favorieten herstellen vanuit lokale bibliotheek...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Spring naar de seriessectie",
|
||||
"SWITCH_PLAYER_LABEL": "Speler wisselen naar {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Stel {{name}} in als de actieve videospeler",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Speler ingesteld op {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Speler ingesteld op {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Accountgegevens van het Stalker-portaal tonen"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Afspeellijst vernieuwen",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Bibliotheekgegevens worden bewaard...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Laden",
|
||||
"RETRY": "Opnieuw proberen",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Accountinformatie",
|
||||
"ACCOUNT": "Account",
|
||||
"PORTAL_INFO": "Portaal",
|
||||
"LOGIN": "Gebruikersnaam",
|
||||
"STATUS": "Status",
|
||||
"ACTIVE": "Actief",
|
||||
"INACTIVE": "Inactief",
|
||||
"TARIFF_PLAN": "Tariefplan",
|
||||
"EXPIRES": "Verloopt",
|
||||
"DAYS_LEFT": "Dagen resterend",
|
||||
"EXPIRED": "Verlopen",
|
||||
"MAC_ADDRESS": "MAC-adres",
|
||||
"PHONE": "Telefoon",
|
||||
"PORTAL_URL": "Portaal-URL",
|
||||
"PORTAL_TYPE_FULL": "Ministra-portaal",
|
||||
"PORTAL_TYPE_LEGACY": "Klassiek portaal (portal.php)",
|
||||
"CACHED_BADGE": "Opgeslagen gegevens",
|
||||
"REFRESH_FAILED": "Het portaal is onbereikbaar — de bij het toevoegen opgeslagen gegevens worden getoond.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Geen accountgegevens",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Dit portaal publiceert geen accountinformatie voor dit apparaat."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·O{{episode}}",
|
||||
"NOT_YET_SYNCED": "Jeszcze niezsynchronizowane",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Wygasa za {{days}} dn.",
|
||||
"SOURCE_EXPIRED": "Wygasło"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Tylko załadowane kanały",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Paleta poleceń (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Otwórz paletę poleceń",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Pokaż lub ukryj panel kategorii",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Kategorie",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Pokaż lub ukryj panel filtrów",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtry",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Pokaż lub ukryj panel sekcji ustawień",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Sekcje ustawień",
|
||||
"ADD_PLAYLIST_ALL": "Dodaj listę odtwarzania",
|
||||
"ADD_PLAYLIST": "Dodaj listę odtwarzania",
|
||||
"OPEN_DOWNLOADS": "Otwórz pobrane",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Biblioteka lokalna",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Oczekiwanie na odpowiedź dostawcy.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Przygotowywanie szybszego przełączania list odtwarzania na następną wizytę.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Odczytywanie biblioteki lokalnej — dane nie są pobierane od dostawcy.",
|
||||
"XTREAM_IMPORT_PREPARING": "Przygotowywanie biblioteki lokalnej...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Wczytywanie zapisanego katalogu z tego urządzenia...",
|
||||
"XTREAM_IMPORT_LOADING": "Pobieranie danych listy odtwarzania ze źródła...",
|
||||
"XTREAM_IMPORT_SAVING": "Zapisywanie danych listy odtwarzania lokalnie...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Przywracanie ulubionych z biblioteki lokalnej...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Przejdź do sekcji seriali",
|
||||
"SWITCH_PLAYER_LABEL": "Przełącz odtwarzacz na {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Ustaw {{name}} jako aktywny odtwarzacz wideo",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Odtwarzacz ustawiony na {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Odtwarzacz ustawiony na {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Pokaż dane konta portalu Stalker"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Odświeżanie listy odtwarzania",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Zachowywanie danych biblioteki...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Ładowanie",
|
||||
"RETRY": "Ponów",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Informacje o koncie",
|
||||
"ACCOUNT": "Konto",
|
||||
"PORTAL_INFO": "Portal",
|
||||
"LOGIN": "Login",
|
||||
"STATUS": "Status",
|
||||
"ACTIVE": "Aktywne",
|
||||
"INACTIVE": "Nieaktywne",
|
||||
"TARIFF_PLAN": "Plan taryfowy",
|
||||
"EXPIRES": "Wygasa",
|
||||
"DAYS_LEFT": "Pozostało dni",
|
||||
"EXPIRED": "Wygasło",
|
||||
"MAC_ADDRESS": "Adres MAC",
|
||||
"PHONE": "Telefon",
|
||||
"PORTAL_URL": "Adres URL portalu",
|
||||
"PORTAL_TYPE_FULL": "Portal Ministra",
|
||||
"PORTAL_TYPE_LEGACY": "Klasyczny portal (portal.php)",
|
||||
"CACHED_BADGE": "Zapisane dane",
|
||||
"REFRESH_FAILED": "Portal jest niedostępny — wyświetlane są dane zapisane podczas dodawania portalu.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Brak danych konta",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Ten portal nie udostępnia informacji o koncie dla tego urządzenia."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Ainda não sincronizado",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expira em {{days}} d",
|
||||
"SOURCE_EXPIRED": "Expirada"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Apenas canais carregados",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Paleta de comandos (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Abrir paleta de comandos",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Mostrar ou ocultar o painel de categorias",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Categorias",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Mostrar ou ocultar o painel de filtros",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtros",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Mostrar ou ocultar o painel de seções das configurações",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Seções das configurações",
|
||||
"ADD_PLAYLIST_ALL": "Adicionar playlist",
|
||||
"ADD_PLAYLIST": "Adicionar playlist",
|
||||
"OPEN_DOWNLOADS": "Abrir downloads",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Biblioteca local",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Aguardando resposta do provedor.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparando troca mais rápida de playlist para a próxima visita.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Lendo a biblioteca local — nenhum dado é baixado do provedor.",
|
||||
"XTREAM_IMPORT_PREPARING": "Preparando biblioteca local...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Carregando o catálogo salvo deste dispositivo...",
|
||||
"XTREAM_IMPORT_LOADING": "Buscando dados da playlist na fonte...",
|
||||
"XTREAM_IMPORT_SAVING": "Salvando dados da playlist localmente...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restaurando favoritos da biblioteca local...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Ir para a seção de séries",
|
||||
"SWITCH_PLAYER_LABEL": "Alternar reprodutor para {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Definir {{name}} como reprodutor de vídeo ativo",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Reprodutor definido como {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Reprodutor definido como {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar dados da conta do portal Stalker"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Atualizando playlist",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Preservando os dados da sua biblioteca...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "A carregar",
|
||||
"RETRY": "Tentar novamente",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Informações da conta",
|
||||
"ACCOUNT": "Conta",
|
||||
"PORTAL_INFO": "Portal",
|
||||
"LOGIN": "Utilizador",
|
||||
"STATUS": "Estado",
|
||||
"ACTIVE": "Ativa",
|
||||
"INACTIVE": "Inativa",
|
||||
"TARIFF_PLAN": "Plano tarifário",
|
||||
"EXPIRES": "Expira",
|
||||
"DAYS_LEFT": "Dias restantes",
|
||||
"EXPIRED": "Expirada",
|
||||
"MAC_ADDRESS": "Endereço MAC",
|
||||
"PHONE": "Telefone",
|
||||
"PORTAL_URL": "URL do portal",
|
||||
"PORTAL_TYPE_FULL": "Portal Ministra",
|
||||
"PORTAL_TYPE_LEGACY": "Portal clássico (portal.php)",
|
||||
"CACHED_BADGE": "Dados guardados",
|
||||
"REFRESH_FAILED": "Não foi possível contactar o portal — a mostrar os dados guardados quando o portal foi adicionado.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Sem dados da conta",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Este portal não publica informações da conta para este dispositivo."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
|
||||
"NOT_YET_SYNCED": "Еще не синхронизировано",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Недавно просмотренное прямое ТВ",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Избранные фильмы и сериалы"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Избранные фильмы и сериалы",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Истекает через {{days}} дн.",
|
||||
"SOURCE_EXPIRED": "Истёк"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Только загруженные каналы",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Палитра команд (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Открыть палитру команд",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Показать или скрыть панель категорий",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Категории",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Показать или скрыть панель фильтров",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Фильтры",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Показать или скрыть панель разделов настроек",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Разделы настроек",
|
||||
"ADD_PLAYLIST_ALL": "Добавить плейлист (все плейлисты)",
|
||||
"ADD_PLAYLIST": "Добавить плейлист",
|
||||
"OPEN_DOWNLOADS": "Открыть загрузки",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Локальная библиотека",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Ожидание ответа от провайдера.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Подготавливает более быстрые переключения плейлиста при следующем открытии.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Чтение локальной библиотеки — данные от провайдера не загружаются.",
|
||||
"XTREAM_IMPORT_PREPARING": "Подготовка локальной библиотеки...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Загрузка сохранённого каталога с этого устройства...",
|
||||
"XTREAM_IMPORT_LOADING": "Получение данных плейлиста из источника...",
|
||||
"XTREAM_IMPORT_SAVING": "Сохранение данных плейлиста локально...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Восстановление избранного из локальной библиотеки...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Перейти к разделу сериалов",
|
||||
"SWITCH_PLAYER_LABEL": "Переключить плеер на {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Сделать {{name}} активным видеоплеером",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Плеер переключен на {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Плеер переключен на {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Показать данные аккаунта Stalker-портала"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Обновление плейлиста",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Сохраняем данные вашей библиотеки...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Загрузка",
|
||||
"RETRY": "Повторить",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Информация об аккаунте",
|
||||
"ACCOUNT": "Аккаунт",
|
||||
"PORTAL_INFO": "Портал",
|
||||
"LOGIN": "Логин",
|
||||
"STATUS": "Статус",
|
||||
"ACTIVE": "Активен",
|
||||
"INACTIVE": "Неактивен",
|
||||
"TARIFF_PLAN": "Тарифный план",
|
||||
"EXPIRES": "Истекает",
|
||||
"DAYS_LEFT": "Осталось дней",
|
||||
"EXPIRED": "Истёк",
|
||||
"MAC_ADDRESS": "MAC-адрес",
|
||||
"PHONE": "Телефон",
|
||||
"PORTAL_URL": "URL портала",
|
||||
"PORTAL_TYPE_FULL": "Портал Ministra",
|
||||
"PORTAL_TYPE_LEGACY": "Классический портал (portal.php)",
|
||||
"CACHED_BADGE": "Сохранённые данные",
|
||||
"REFRESH_FAILED": "Портал недоступен — показаны данные, сохранённые при добавлении портала.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Нет данных об аккаунте",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Этот портал не предоставляет информацию об аккаунте для этого устройства."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·B{{episode}}",
|
||||
"NOT_YET_SYNCED": "Henüz eşitlenmedi",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} gün içinde sona erer",
|
||||
"SOURCE_EXPIRED": "Süresi doldu"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "Yalnızca yüklenmiş kanallar",
|
||||
"COMMAND_PALETTE_SHORTCUT": "Komut paleti (Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "Komut paletini aç",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "Kategori panelini aç/kapat",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "Kategoriler",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "Filtre panelini aç/kapat",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "Filtreler",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "Ayar bölümleri panelini aç/kapat",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "Ayar bölümleri",
|
||||
"ADD_PLAYLIST_ALL": "Oynatma listesi ekle",
|
||||
"ADD_PLAYLIST": "Oynatma listesi ekle",
|
||||
"OPEN_DOWNLOADS": "İndirilenleri aç",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "Yerel kütüphane",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "Sağlayıcı yanıtı bekleniyor.",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "Bir sonraki ziyaret için daha hızlı oynatma listesi geçişi hazırlanıyor.",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "Yerel katalog okunuyor — sağlayıcıdan veri indirilmiyor.",
|
||||
"XTREAM_IMPORT_PREPARING": "Yerel kütüphane hazırlanıyor...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "Bu cihazdan kaydedilmiş katalog yükleniyor...",
|
||||
"XTREAM_IMPORT_LOADING": "Kaynaktan oynatma listesi verileri alınıyor...",
|
||||
"XTREAM_IMPORT_SAVING": "Oynatma listesi verileri yerel olarak kaydediliyor...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "Yerel kütüphaneden favoriler geri yükleniyor...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "Diziler bölümüne atla",
|
||||
"SWITCH_PLAYER_LABEL": "Oynatıcıyı {{name}} olarak değiştir",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "{{name}} oynatıcısını etkin video oynatıcı olarak ayarla",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Oynatıcı {{name}} olarak ayarlandı"
|
||||
"SWITCH_PLAYER_FEEDBACK": "Oynatıcı {{name}} olarak ayarlandı",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker portalı hesap bilgilerini göster"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Oynatma listesi yenileniyor",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Kitaplık verileriniz korunuyor...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "Yükleniyor",
|
||||
"RETRY": "Yeniden dene",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "Hesap Bilgileri",
|
||||
"ACCOUNT": "Hesap",
|
||||
"PORTAL_INFO": "Portal",
|
||||
"LOGIN": "Kullanıcı adı",
|
||||
"STATUS": "Durum",
|
||||
"ACTIVE": "Etkin",
|
||||
"INACTIVE": "Devre dışı",
|
||||
"TARIFF_PLAN": "Tarife planı",
|
||||
"EXPIRES": "Bitiş tarihi",
|
||||
"DAYS_LEFT": "Kalan gün",
|
||||
"EXPIRED": "Süresi doldu",
|
||||
"MAC_ADDRESS": "MAC adresi",
|
||||
"PHONE": "Telefon",
|
||||
"PORTAL_URL": "Portal URL’si",
|
||||
"PORTAL_TYPE_FULL": "Ministra portalı",
|
||||
"PORTAL_TYPE_LEGACY": "Klasik portal (portal.php)",
|
||||
"CACHED_BADGE": "Kayıtlı veriler",
|
||||
"REFRESH_FAILED": "Portala ulaşılamadı — portal eklenirken kaydedilen veriler gösteriliyor.",
|
||||
"NO_ACCOUNT_DATA_TITLE": "Hesap verisi yok",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "Bu portal, bu cihaz için hesap bilgisi yayınlamıyor."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "尚未同步",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} 天后到期",
|
||||
"SOURCE_EXPIRED": "已过期"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "仅已加载的频道",
|
||||
"COMMAND_PALETTE_SHORTCUT": "命令面板(Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "打开命令面板",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "切换分类面板",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "分类",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "切换筛选面板",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "筛选",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "切换设置栏目面板",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "设置栏目",
|
||||
"ADD_PLAYLIST_ALL": "添加播放列表",
|
||||
"ADD_PLAYLIST": "添加播放列表",
|
||||
"OPEN_DOWNLOADS": "打开下载",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "本地库",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "正在等待提供商响应。",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "正在为下次访问准备更快的播放列表切换。",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "正在读取本地库——不从提供商下载数据。",
|
||||
"XTREAM_IMPORT_PREPARING": "正在准备本地库…",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "正在从此设备加载已保存的目录...",
|
||||
"XTREAM_IMPORT_LOADING": "正在从源获取播放列表数据…",
|
||||
"XTREAM_IMPORT_SAVING": "正在本地保存播放列表数据…",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "正在从本地库恢复收藏…",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "跳转到剧集区",
|
||||
"SWITCH_PLAYER_LABEL": "切换播放器为 {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "将 {{name}} 设为当前视频播放器",
|
||||
"SWITCH_PLAYER_FEEDBACK": "播放器已设为 {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "播放器已设为 {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "显示 Stalker 门户账户信息"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "正在刷新播放列表",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒体库数据...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "加载中",
|
||||
"RETRY": "重试",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "账户信息",
|
||||
"ACCOUNT": "账户",
|
||||
"PORTAL_INFO": "门户",
|
||||
"LOGIN": "登录名",
|
||||
"STATUS": "状态",
|
||||
"ACTIVE": "有效",
|
||||
"INACTIVE": "无效",
|
||||
"TARIFF_PLAN": "资费套餐",
|
||||
"EXPIRES": "到期时间",
|
||||
"DAYS_LEFT": "剩余天数",
|
||||
"EXPIRED": "已过期",
|
||||
"MAC_ADDRESS": "MAC 地址",
|
||||
"PHONE": "电话",
|
||||
"PORTAL_URL": "门户 URL",
|
||||
"PORTAL_TYPE_FULL": "Ministra 门户",
|
||||
"PORTAL_TYPE_LEGACY": "传统门户(portal.php)",
|
||||
"CACHED_BADGE": "已保存的数据",
|
||||
"REFRESH_FAILED": "无法连接门户 — 正在显示添加门户时保存的数据。",
|
||||
"NO_ACCOUNT_DATA_TITLE": "无账户数据",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "该门户不为此设备提供账户信息。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1221,7 +1221,9 @@
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "尚未同步",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} 天後到期",
|
||||
"SOURCE_EXPIRED": "已過期"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
@@ -1256,6 +1258,12 @@
|
||||
"SEARCH_STATUS_LOADED_ONLY": "僅限已載入的頻道",
|
||||
"COMMAND_PALETTE_SHORTCUT": "命令選擇區(Ctrl/Cmd+K)",
|
||||
"OPEN_COMMAND_PALETTE": "開啟命令選擇區",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOGGLE": "切換類別面板",
|
||||
"CONTEXT_DRAWER_CATEGORIES_TOOLTIP": "類別",
|
||||
"CONTEXT_DRAWER_FILTERS_TOGGLE": "切換篩選面板",
|
||||
"CONTEXT_DRAWER_FILTERS_TOOLTIP": "篩選",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOGGLE": "切換設定區段面板",
|
||||
"CONTEXT_DRAWER_SETTINGS_TOOLTIP": "設定區段",
|
||||
"ADD_PLAYLIST_ALL": "新增播放清單",
|
||||
"ADD_PLAYLIST": "新增播放清單",
|
||||
"OPEN_DOWNLOADS": "開啟下載",
|
||||
@@ -1298,7 +1306,9 @@
|
||||
"XTREAM_IMPORT_LOCAL_BADGE": "本機媒體庫",
|
||||
"XTREAM_IMPORT_DETAIL_REMOTE": "正在等待供應商回應。",
|
||||
"XTREAM_IMPORT_DETAIL_LOCAL": "正在為下次造訪準備更快速的播放清單切換。",
|
||||
"XTREAM_IMPORT_DETAIL_CACHED": "讀取本機媒體庫——不會從供應商下載任何資料。",
|
||||
"XTREAM_IMPORT_PREPARING": "正在準備本機媒體庫...",
|
||||
"XTREAM_IMPORT_LOADING_CACHED": "正在從此裝置載入已儲存的目錄...",
|
||||
"XTREAM_IMPORT_LOADING": "正在從來源擷取播放清單資料...",
|
||||
"XTREAM_IMPORT_SAVING": "正在將播放清單資料儲存至本機...",
|
||||
"XTREAM_IMPORT_RESTORING_FAVORITES": "正在從本機媒體庫還原我的最愛...",
|
||||
@@ -1348,7 +1358,8 @@
|
||||
"GO_TO_SERIES_DESCRIPTION": "跳至影集區段",
|
||||
"SWITCH_PLAYER_LABEL": "切換播放器至 {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "將 {{name}} 設為使用中的影片播放器",
|
||||
"SWITCH_PLAYER_FEEDBACK": "播放器已設為 {{name}}"
|
||||
"SWITCH_PLAYER_FEEDBACK": "播放器已設為 {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "顯示 Stalker 入口網站帳戶資訊"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "正在重新整理播放清單",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒體庫資料...",
|
||||
@@ -1458,5 +1469,31 @@
|
||||
"NO_ITEMS_TITLE": "Nothing watched yet",
|
||||
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
|
||||
}
|
||||
},
|
||||
"LOADING": "載入中",
|
||||
"RETRY": "重試",
|
||||
"STALKER": {
|
||||
"ACCOUNT_INFO": {
|
||||
"TITLE": "帳戶資訊",
|
||||
"ACCOUNT": "帳戶",
|
||||
"PORTAL_INFO": "入口網站",
|
||||
"LOGIN": "登入名稱",
|
||||
"STATUS": "狀態",
|
||||
"ACTIVE": "有效",
|
||||
"INACTIVE": "無效",
|
||||
"TARIFF_PLAN": "資費方案",
|
||||
"EXPIRES": "到期日",
|
||||
"DAYS_LEFT": "剩餘天數",
|
||||
"EXPIRED": "已過期",
|
||||
"MAC_ADDRESS": "MAC 位址",
|
||||
"PHONE": "電話",
|
||||
"PORTAL_URL": "入口網站 URL",
|
||||
"PORTAL_TYPE_FULL": "Ministra 入口網站",
|
||||
"PORTAL_TYPE_LEGACY": "傳統入口網站(portal.php)",
|
||||
"CACHED_BADGE": "已儲存的資料",
|
||||
"REFRESH_FAILED": "無法連線至入口網站 — 顯示新增入口網站時儲存的資料。",
|
||||
"NO_ACCOUNT_DATA_TITLE": "沒有帳戶資料",
|
||||
"NO_ACCOUNT_DATA_DESCRIPTION": "此入口網站不提供此裝置的帳戶資訊。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,14 @@ html {
|
||||
--app-provider-stalker: #10b981;
|
||||
--app-provider-m3u: #d97706;
|
||||
|
||||
// Subscription-expiry chips (dashboard source cards). Text/surface pairs
|
||||
// are picked per theme to keep >= 4.5:1 small-text contrast against the
|
||||
// composited chip background.
|
||||
--app-expiry-warn-text: #8a5300;
|
||||
--app-expiry-warn-surface: color-mix(in srgb, #d97706 16%, transparent);
|
||||
--app-expiry-expired-text: #b3261e;
|
||||
--app-expiry-expired-surface: color-mix(in srgb, #f44336 14%, transparent);
|
||||
|
||||
// Hero
|
||||
--app-accent-gradient: linear-gradient(
|
||||
135deg,
|
||||
@@ -188,6 +196,17 @@ html {
|
||||
--app-provider-stalker: #34d399;
|
||||
--app-provider-m3u: #fbbf24;
|
||||
|
||||
// Subscription-expiry chips — brighter text for the dark surfaces;
|
||||
// expired matches the --app-live-color accent family.
|
||||
--app-expiry-warn-text: #fbbf24;
|
||||
--app-expiry-warn-surface: color-mix(in srgb, #fbbf24 16%, transparent);
|
||||
--app-expiry-expired-text: #ff8f8f;
|
||||
--app-expiry-expired-surface: color-mix(
|
||||
in srgb,
|
||||
#ff8f8f 16%,
|
||||
transparent
|
||||
);
|
||||
|
||||
// Hero section
|
||||
--app-accent-gradient: linear-gradient(
|
||||
135deg,
|
||||
|
||||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user