mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
chore(deps): merge master into Nx update
This commit is contained in:
commit
e5c54f01ad
83 files changed
+7363
-895
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
area: stalker
|
||||
---
|
||||
|
||||
Stalker portals that ask for a login and password now work: the import dialog
|
||||
gained username/password fields and the app completes the portal's do_auth
|
||||
step. When a portal refuses access, its own explanation is shown instead of a
|
||||
generic error, saved sessions resume without re-authenticating, and the
|
||||
keep-alive ping follows the portal's cadence.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
---
|
||||
|
||||
Stalker channels that the portal serves directly now start without an extra
|
||||
link request to the portal, so they open faster and no longer fail when that
|
||||
request does. Channels the portal does proxy still get their temporary link,
|
||||
and radio stations the portal proxies now get one too instead of playing a URL
|
||||
the portal never meant to serve.
|
||||
@@ -698,6 +698,23 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- Xtream Codes API (`username`, `password`, `serverUrl`)
|
||||
- Stalker portal (`macAddress`, `url`)
|
||||
|
||||
**Stalker playback links**: `create_link` runs only when the catalog row sets
|
||||
`use_http_tmp_link` or `use_load_balancing`; otherwise the static `cmd` plays
|
||||
directly. One helper decides
|
||||
(`resolveStalkerStaticPlaybackUrl` in
|
||||
`libs/portal/stalker/data-access/.../stalker-link-semantics.utils.ts`), applied
|
||||
by `fetchStalkerPlaybackLink()` for ITV/VOD/radio and by
|
||||
`StreamResolverService` for Favorites/Recently Viewed. It falls back to
|
||||
`create_link` for anything it cannot resolve alone: no row to read flags from,
|
||||
a relative/query-only command (the VOD `has_files` rewrite), a non-HTTP scheme,
|
||||
or a loopback host; an episode (`series` set) always mints, since the parameter
|
||||
selects the episode server-side. Temporary links live ~5 s, so no resolved URL
|
||||
is persisted or replayed — favorites and recently-viewed store the `cmd`,
|
||||
playback positions store ids, and the main-process context map stores headers
|
||||
keyed by origin+path. Downloads are the one exception (they must retry a URL).
|
||||
`forced_storage`/`play_token` are deliberately unwired. Contract:
|
||||
`docs/architecture/stalker-portal.md` ("Playback Link Resolution").
|
||||
|
||||
**Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed
|
||||
on the command line, opened through a file association, or delivered by macOS'
|
||||
`open-file` event is normalized to an absolute path in the main process
|
||||
@@ -1094,6 +1111,16 @@ engine` (restart required) or
|
||||
- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes, routing by the observed portal MODE rather than the URL shape (full mode: handshake+`get_profile`; simple mode: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields), and re-routing when a lazy repair changes the mode mid-request. 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.
|
||||
|
||||
**Stalker Session Authentication**:
|
||||
|
||||
- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-portal-error.ts` and `stalker-response-classification.ts`.
|
||||
- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = blocked, `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it.
|
||||
- Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code.
|
||||
- Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections.
|
||||
- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start.
|
||||
- Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting.
|
||||
- Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle").
|
||||
|
||||
**Favorites and Recently Viewed**:
|
||||
|
||||
- Per-playlist favorites and global favorites
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
classifyStalkerAuthFailureBody,
|
||||
createStalkerAuthFailureMarker,
|
||||
PortalDebugEvent,
|
||||
STALKER_REQUEST,
|
||||
buildStalkerIdentityRequestContext,
|
||||
@@ -111,6 +113,18 @@ ipcMain.handle(
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
// The portal answers auth failures with HTTP 200 and a plain
|
||||
// text/html body ("Authorization failed.", "Access denied.",
|
||||
// "Unauthorized request."). Classify them here so the renderer
|
||||
// receives a structured marker instead of pattern-matching raw
|
||||
// response text.
|
||||
const authFailureBody = classifyStalkerAuthFailureBody(
|
||||
response.data
|
||||
);
|
||||
const responseData = authFailureBody
|
||||
? createStalkerAuthFailureMarker(authFailureBody)
|
||||
: response.data;
|
||||
|
||||
// Return the response data
|
||||
if (
|
||||
params.action === 'create_link' &&
|
||||
@@ -136,12 +150,12 @@ ipcMain.handle(
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: 'success',
|
||||
request: debugRequest,
|
||||
response: response.data,
|
||||
response: responseData,
|
||||
};
|
||||
emitPortalDebugEvent(debugEvent);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
if (payload.requestId) {
|
||||
const debugEvent: PortalDebugEvent = {
|
||||
|
||||
@@ -106,4 +106,47 @@ describe('stalker playback context', () => {
|
||||
expect(headers).not.toHaveProperty('Cookie');
|
||||
expect(headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
describe('temporary-link retention', () => {
|
||||
// A Stalker temporary link expires after ~5 s. This map is a
|
||||
// header lookup keyed BY the stream URL the player is already
|
||||
// opening — it must never become a place a stale URL can be read
|
||||
// back out of and replayed.
|
||||
it('stores headers only, never the URL it was keyed with', () => {
|
||||
const streamUrl =
|
||||
'http://tmp-link.example.test/ch/1?token=SECRET-TMP';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl,
|
||||
portalUrl:
|
||||
'http://tmp-link.example.test/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
|
||||
|
||||
expect(Object.keys(headers).length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(headers)).not.toContain('SECRET-TMP');
|
||||
expect(JSON.stringify(headers)).not.toContain('/ch/1');
|
||||
});
|
||||
|
||||
it('matches a re-minted link that differs only in its expiring query', () => {
|
||||
// Consecutive create_link calls return the same path with a fresh
|
||||
// token; the lookup keys on origin+path so the second link still
|
||||
// finds its portal headers instead of playing bare.
|
||||
const firstLink = 'http://tmp-link2.example.test/ch/7?token=first';
|
||||
const secondLink = 'http://tmp-link2.example.test/ch/7?token=second';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl: firstLink,
|
||||
portalUrl:
|
||||
'http://tmp-link2.example.test/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
getStalkerPlaybackContextHeaders(secondLink)?.['Cookie']
|
||||
).toContain(`mac=${macAddress}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -82,9 +82,17 @@ actually get wrong:
|
||||
| `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 |
|
||||
| `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 drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) |
|
||||
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
|
||||
| `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` |
|
||||
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
|
||||
|
||||
Every generated ITV channel and radio station carries the two temporary-link
|
||||
flags a real portal sends. Outside the `static-channel-cmd` scenario they are
|
||||
`use_http_tmp_link: '1'`, which is the honest annotation of their
|
||||
`ffrt4://…` commands: those are portal-internal pseudo-URLs that only
|
||||
`create_link` can resolve.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|
||||
@@ -17,7 +17,17 @@ export interface RawCategory {
|
||||
censored?: string;
|
||||
}
|
||||
|
||||
export interface RawChannel {
|
||||
/**
|
||||
* The two flags that tell a client whether the row needs `create_link`. Real
|
||||
* portals send them on every ITV/radio row as `'0'`/`'1'` strings; a client
|
||||
* that honours them plays the static `cmd` when both are `'0'`.
|
||||
*/
|
||||
export interface RawTemporaryLinkFlags {
|
||||
use_http_tmp_link: '0' | '1';
|
||||
use_load_balancing: '0' | '1';
|
||||
}
|
||||
|
||||
export interface RawChannel extends RawTemporaryLinkFlags {
|
||||
id: string;
|
||||
name: string;
|
||||
o_name: string;
|
||||
@@ -28,7 +38,7 @@ export interface RawChannel {
|
||||
xmltv_id: string;
|
||||
}
|
||||
|
||||
export interface RawRadioStation {
|
||||
export interface RawRadioStation extends RawTemporaryLinkFlags {
|
||||
id: string;
|
||||
name: string;
|
||||
o_name: string;
|
||||
@@ -238,7 +248,12 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
|
||||
});
|
||||
let channelIndex = 0;
|
||||
for (const cat of data.itvCategories) {
|
||||
const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex);
|
||||
const channels = generateChannels(
|
||||
cat.id,
|
||||
config.itemsPerCategory,
|
||||
channelIndex,
|
||||
config.staticChannelCmd === true
|
||||
);
|
||||
data.channels.set(cat.id, channels);
|
||||
for (const ch of channels) {
|
||||
data.epg.set(ch.id, generateEpg(ch.name));
|
||||
@@ -377,7 +392,12 @@ function generateCategories(
|
||||
// Channel generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateChannels(categoryId: string, count: number, startIndex: number): RawChannel[] {
|
||||
function generateChannels(
|
||||
categoryId: string,
|
||||
count: number,
|
||||
startIndex: number,
|
||||
staticCmd: boolean
|
||||
): RawChannel[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const globalIndex = startIndex + i;
|
||||
const id = String(10000 + globalIndex);
|
||||
@@ -386,11 +406,19 @@ function generateChannels(categoryId: string, count: number, startIndex: number)
|
||||
id,
|
||||
name,
|
||||
o_name: name,
|
||||
cmd: `ffrt4://ch/live/${id}/index.m3u8`,
|
||||
// A static row carries a playable address with the usual
|
||||
// `<solution> <url>` prefix; the default `ffrt4://` command is a
|
||||
// portal-internal pseudo-URL that only `create_link` can resolve,
|
||||
// which is exactly what `use_http_tmp_link` announces.
|
||||
cmd: staticCmd
|
||||
? `ffrt3 ${pickStream(globalIndex)}`
|
||||
: `ffrt4://ch/live/${id}/index.m3u8`,
|
||||
logo: logoUrl(`ch-${id}`),
|
||||
category_id: categoryId,
|
||||
tv_genre_id: categoryId,
|
||||
xmltv_id: `channel-${id}.example`,
|
||||
use_http_tmp_link: staticCmd ? '0' : '1',
|
||||
use_load_balancing: '0',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -414,6 +442,8 @@ function generateRadioStations(
|
||||
tv_genre_id: categoryId,
|
||||
number: String(globalIndex + 1),
|
||||
radio: true,
|
||||
use_http_tmp_link: '1',
|
||||
use_load_balancing: '0',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,10 +11,9 @@ 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.
|
||||
* The e2e suite drives the app through this flow end to end; these handler
|
||||
* tests pin the SERVER half of the contract independently, so a client-side
|
||||
* regression and a mock-side one cannot mask each other.
|
||||
*
|
||||
* Handlers are called directly instead of through the dispatcher: the
|
||||
* dispatcher pulls in every content handler and therefore the faker-based
|
||||
@@ -82,8 +81,8 @@ describe('stalker mock authentication handlers', () => {
|
||||
})['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.
|
||||
// A client could mislabel its first profile as the second auth step;
|
||||
// that parameter alone must not satisfy the scenario.
|
||||
expect(
|
||||
invoke(
|
||||
handleGetProfile,
|
||||
|
||||
@@ -33,10 +33,11 @@ export function handleGetProfile(
|
||||
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.
|
||||
// Gate on the actual do_auth completion, not on auth_second_step. The
|
||||
// client now sends `auth_second_step=0` first and `1` only on the retry
|
||||
// after do_auth, so a parameter check would happen to work — but it would
|
||||
// also silently pass for any client that mislabels its first profile,
|
||||
// which is exactly the protocol mistake this scenario exists to catch.
|
||||
if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) {
|
||||
res.json({
|
||||
js: {
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface ScenarioConfig {
|
||||
* the portal credentials (the "only VLC works" cluster).
|
||||
*/
|
||||
gatedStream?: true;
|
||||
/**
|
||||
* Generate ITV channels that need NO temporary link: a directly playable
|
||||
* `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'`. A client
|
||||
* honouring the flags must play those without calling `create_link`.
|
||||
*/
|
||||
staticChannelCmd?: true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,6 +163,20 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
embeddedSeriesFraction: 0,
|
||||
gatedStream: true,
|
||||
},
|
||||
'00:1a:79:00:00:0a': {
|
||||
name: 'static-channel-cmd',
|
||||
description:
|
||||
'ITV rows that need no temporary link — playable cmd with ' +
|
||||
'use_http_tmp_link/use_load_balancing both 0',
|
||||
seed: 1010,
|
||||
categoryCount: { itv: 2, radio: 1, vod: 1, series: 1 },
|
||||
itemsPerCategory: 5,
|
||||
seasonsPerSeries: 1,
|
||||
episodesPerSeason: 3,
|
||||
isSeriesFraction: 0,
|
||||
embeddedSeriesFraction: 0,
|
||||
staticChannelCmd: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+317
-21
@@ -38,6 +38,21 @@ import {
|
||||
*
|
||||
* Giving each test its own MAC instead would mean inventing a scenario per
|
||||
* test; serializing one file is the cheaper trade.
|
||||
*
|
||||
* ACROSS BROWSER PROJECTS this does NOT hold, and it is a local-run hazard
|
||||
* only. `mode: 'serial'` orders tests within one project; chromium, firefox
|
||||
* and webkit still run the file concurrently against the SAME mock server, so
|
||||
* one project's `beforeEach` reset can drop a session another project is
|
||||
* mid-test on — the auth specs below are the ones that notice, failing as if
|
||||
* the portal had dropped them. CI never sees it: the Web E2E job runs
|
||||
* `--project=chromium` alone (`.github/workflows/e2e-tests.yaml`). If a local
|
||||
* all-project run shows a lone auth failure that passes on rerun, this is why;
|
||||
* `--project=chromium` reproduces CI exactly.
|
||||
*
|
||||
* Most tests here tolerate that reset anyway — they re-authenticate, or assert
|
||||
* that a NEW token appears. The token-reuse test cannot: its assertion IS that
|
||||
* the portal session survives, so it uses per-project MACs held outside
|
||||
* `OWNED_MACS`. See `AUTH_REUSE_MACS`.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
@@ -65,6 +80,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';
|
||||
|
||||
/**
|
||||
* Static-cmd MAC — ITV rows carrying a directly playable `cmd` with
|
||||
* `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that
|
||||
* expects no `create_link` call at all.
|
||||
*/
|
||||
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
|
||||
|
||||
/**
|
||||
* Login-required scenario MAC — get_profile answers status 2 until do_auth
|
||||
* completes with non-empty credentials (empty credentials return {js:false},
|
||||
* as the operator billing script would).
|
||||
*/
|
||||
const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08';
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -73,6 +102,25 @@ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
|
||||
*/
|
||||
const AUTH_FLOW_MAC = '00:1A:79:AD:00:01';
|
||||
const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03';
|
||||
|
||||
/**
|
||||
* The token-reuse test needs the portal session to SURVIVE untouched between
|
||||
* its import and its reload — that persistence is the whole assertion. Every
|
||||
* other test here is reset-tolerant (they either re-authenticate or assert a
|
||||
* new token), so they can share `OWNED_MACS`, which each `beforeEach` clears.
|
||||
*
|
||||
* `mode: 'serial'` only serializes within one project; the browser projects
|
||||
* still run concurrently, so a sibling project's reset would rotate this
|
||||
* session mid-test. Hence one MAC per project, and deliberately NOT in
|
||||
* `OWNED_MACS`: nothing may reset them. Leftover state from an earlier run is
|
||||
* harmless — the import negotiates and adopts its own token first.
|
||||
*/
|
||||
const AUTH_REUSE_MACS: Record<string, string> = {
|
||||
chromium: '00:1A:79:AD:01:01',
|
||||
firefox: '00:1A:79:AD:01:02',
|
||||
webkit: '00:1A:79:AD:01:03',
|
||||
};
|
||||
const AUTH_REUSE_FALLBACK_MAC = '00:1A:79:AD:01:04';
|
||||
/**
|
||||
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
|
||||
* it, so no token is ever adopted and content requests fail permanently.
|
||||
@@ -120,6 +168,8 @@ const OWNED_MACS = [
|
||||
MINIMAL_MAC,
|
||||
EMBEDDED_SERIES_MAC,
|
||||
LEGACY_PAGINATION_MAC,
|
||||
STATIC_CMD_MAC,
|
||||
LOGIN_REQUIRED_MAC,
|
||||
AUTH_FLOW_MAC,
|
||||
AUTH_REAUTH_MAC,
|
||||
AUTH_REJECTED_MAC,
|
||||
@@ -194,9 +244,21 @@ async function addStalkerPortal(
|
||||
*/
|
||||
async function addFullStalkerPortal(
|
||||
page: Page,
|
||||
options: { name?: string; mac: string; expectContent?: boolean }
|
||||
options: {
|
||||
name?: string;
|
||||
mac: string;
|
||||
expectContent?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { name = 'Full Stalker Portal', mac, expectContent = true } = options;
|
||||
const {
|
||||
name = 'Full Stalker Portal',
|
||||
mac,
|
||||
expectContent = true,
|
||||
username,
|
||||
password,
|
||||
} = options;
|
||||
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
@@ -206,6 +268,12 @@ async function addFullStalkerPortal(
|
||||
await setInputValue(dialog.locator('input#title'), name);
|
||||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||||
await setInputValue(dialog.locator('input#macAddress'), mac);
|
||||
if (username !== undefined) {
|
||||
await setInputValue(dialog.locator('input#username'), username);
|
||||
}
|
||||
if (password !== undefined) {
|
||||
await setInputValue(dialog.locator('input#password'), password);
|
||||
}
|
||||
|
||||
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
@@ -681,6 +749,62 @@ test('@stalker create_link returns a playable stream URL', async ({
|
||||
expect(streamUrl).toMatch(/\.m3u8$/);
|
||||
});
|
||||
|
||||
/**
|
||||
* Play the first channel of the first ITV category and report every
|
||||
* `create_link` request the page made while doing so.
|
||||
*/
|
||||
async function playFirstItvChannel(page: Page): Promise<string[]> {
|
||||
const createLinkRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('action=create_link')) {
|
||||
createLinkRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
await channels.first().click();
|
||||
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||||
await expect(page.locator('app-web-player-view')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
return createLinkRequests;
|
||||
}
|
||||
|
||||
test('@stalker ITV plays an unflagged channel without minting a link', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The reference client only calls create_link when the row sets
|
||||
// use_http_tmp_link or use_load_balancing; this portal sets neither, so
|
||||
// the static cmd must reach the player untouched. The companion test
|
||||
// below proves the recorder does see a create_link when one is due.
|
||||
await addStalkerPortal(page, {
|
||||
name: 'Static Cmd Portal',
|
||||
mac: STATIC_CMD_MAC,
|
||||
});
|
||||
|
||||
expect(await playFirstItvChannel(page)).toEqual([]);
|
||||
});
|
||||
|
||||
test('@stalker ITV mints a link for a channel that asks for one', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page, { name: 'Tmp Link Portal' });
|
||||
|
||||
const createLinkRequests = await playFirstItvChannel(page);
|
||||
|
||||
expect(createLinkRequests.length).toBeGreaterThan(0);
|
||||
expect(createLinkRequests[0]).toContain('type=itv');
|
||||
});
|
||||
|
||||
test('@stalker mock server returns radio categories and stations', async ({
|
||||
request,
|
||||
}) => {
|
||||
@@ -917,12 +1041,171 @@ test.describe('@stalker full portal authentication', () => {
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('never surfaces the portal plain-text auth failure as content', async ({
|
||||
test('completes the documented login flow behind get_profile status 2', async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests = recordPortalRequests(page);
|
||||
// The second auth step must only be claimed on the retry AFTER
|
||||
// do_auth — a client that always sends auth_second_step=1 works
|
||||
// against the mock but violates the documented protocol.
|
||||
const profileSteps: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (!url.pathname.endsWith('/stalker')) {
|
||||
return;
|
||||
}
|
||||
if (url.searchParams.get('action') !== 'get_profile') {
|
||||
return;
|
||||
}
|
||||
profileSteps.push(url.searchParams.get('auth_second_step') ?? '');
|
||||
});
|
||||
|
||||
await addFullStalkerPortal(page, {
|
||||
mac: LOGIN_REQUIRED_MAC,
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
// The mock only adopts the token (and answers content) after do_auth
|
||||
// completed with non-empty credentials, so rendered categories prove
|
||||
// the whole status 2 -> do_auth -> profile retry chain ran.
|
||||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const actions = requests.map((entry) => entry.action);
|
||||
expect(actions).toContain('do_auth');
|
||||
expect(actions.indexOf('get_profile')).toBeLessThan(
|
||||
actions.indexOf('do_auth')
|
||||
);
|
||||
expect(actions.lastIndexOf('get_profile')).toBeGreaterThan(
|
||||
actions.indexOf('do_auth')
|
||||
);
|
||||
|
||||
expect(profileSteps[0]).toBe('0');
|
||||
expect(profileSteps).toContain('1');
|
||||
|
||||
// Content only flows once the token is adopted, which the mock does
|
||||
// only after do_auth completed. Look AFTER the last get_profile:
|
||||
// discovery's classification probe is itself a token-less
|
||||
// `get_genres`, so the first CONTENT_ACTIONS hit is that probe.
|
||||
const contentRequest = requests
|
||||
.slice(actions.lastIndexOf('get_profile') + 1)
|
||||
.find((entry) => CONTENT_ACTIONS.includes(entry.action));
|
||||
expect(contentRequest).toBeDefined();
|
||||
expect(contentRequest?.token).toBeTruthy();
|
||||
});
|
||||
|
||||
test('explains a login-required portal and recovers once credentials are entered', async ({
|
||||
page,
|
||||
}) => {
|
||||
// First attempt without credentials: the import must fail with the
|
||||
// portal's actual reason, not a generic "check URL and MAC" error.
|
||||
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'), 'Login Portal');
|
||||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||||
await setInputValue(
|
||||
dialog.locator('input#macAddress'),
|
||||
LOGIN_REQUIRED_MAC
|
||||
);
|
||||
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/requires a login and password/i)
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
// The dialog stays open so the user can add the credentials.
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Second attempt with credentials in the same dialog succeeds.
|
||||
await setInputValue(dialog.locator('input#username'), 'user');
|
||||
await setInputValue(dialog.locator('input#password'), 'secret');
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
await expect(dialog).toBeHidden({ timeout: 30_000 });
|
||||
|
||||
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
|
||||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('reuses the persisted token after a reload instead of re-running get_profile', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requests = recordPortalRequests(page);
|
||||
const mac =
|
||||
AUTH_REUSE_MACS[testInfo.project.name] ?? AUTH_REUSE_FALLBACK_MAC;
|
||||
|
||||
await addFullStalkerPortal(page, { mac });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
requests.some(
|
||||
(entry) =>
|
||||
CONTENT_ACTIONS.includes(entry.action) &&
|
||||
entry.token
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const sessionToken = requests.find(
|
||||
(entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token
|
||||
)?.token;
|
||||
expect(sessionToken).toBeTruthy();
|
||||
|
||||
const requestCountBeforeReload = requests.length;
|
||||
await page.reload();
|
||||
|
||||
// Wait on the UI rather than the request log: a reload restores the
|
||||
// route, boots the app and re-authenticates before the first content
|
||||
// request goes out, and rendered categories prove that whole chain
|
||||
// finished (the portal only answers content for an adopted token).
|
||||
await expect(page.locator('.category-item').first()).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const afterReload = requests.slice(requestCountBeforeReload);
|
||||
|
||||
// Content came back under the SAME token, negotiated by re-presenting
|
||||
// the persisted one in the handshake.
|
||||
expect(
|
||||
afterReload.filter(
|
||||
(entry) =>
|
||||
CONTENT_ACTIONS.includes(entry.action) &&
|
||||
entry.token === sessionToken
|
||||
).length
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// The handshake re-presented the persisted token...
|
||||
const reloadHandshake = afterReload.find(
|
||||
(entry) => entry.action === 'handshake'
|
||||
);
|
||||
expect(reloadHandshake?.token).toBe(sessionToken);
|
||||
|
||||
// ...and because the portal returned it unchanged (idempotent
|
||||
// handshake, still-adopted session), the profile round trip was
|
||||
// skipped entirely.
|
||||
expect(
|
||||
afterReload.filter((entry) => entry.action === 'get_profile')
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('refuses a rejected portal at import and never renders its plain-text failure', 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
|
||||
@@ -940,25 +1223,38 @@ test.describe('@stalker full portal authentication', () => {
|
||||
).json();
|
||||
expect(failureBody.payload).toBe('Authorization failed.');
|
||||
|
||||
await addFullStalkerPortal(page, {
|
||||
mac: AUTH_REJECTED_MAC,
|
||||
expectContent: false,
|
||||
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'), 'Rejected Portal');
|
||||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||||
await setInputValue(
|
||||
dialog.locator('input#macAddress'),
|
||||
AUTH_REJECTED_MAC
|
||||
);
|
||||
|
||||
const addButton = dialog.getByRole('button', {
|
||||
name: 'Add',
|
||||
exact: true,
|
||||
});
|
||||
await expect(addButton).toBeEnabled({ timeout: 10_000 });
|
||||
await addButton.click();
|
||||
|
||||
// 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);
|
||||
// `status: 1` means the portal refused this device, so the import must
|
||||
// stop there instead of persisting a portal that can never load. (It
|
||||
// used to be treated as success whenever the portal sent no msg text,
|
||||
// which imported a dead source.)
|
||||
await expect(page.getByText(/refused access/i)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/stalker/);
|
||||
|
||||
// ...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.
|
||||
// And the raw portal response is never painted as UI. A portal answers
|
||||
// auth failures with HTTP 200 + plain text, so an app that trusts the
|
||||
// status code would happily render these strings.
|
||||
await expect(page.locator('body')).not.toContainText(
|
||||
'Authorization failed.'
|
||||
);
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "رابط الخادم",
|
||||
"URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com/stalker_portal)",
|
||||
"ADD": "إضافة",
|
||||
"VALIDATING": "جارٍ التحقق من البوابة..."
|
||||
"VALIDATING": "جارٍ التحقق من البوابة...",
|
||||
"CREDENTIALS_HINT": "مطلوب فقط عندما تطلب البوابة اسم مستخدم وكلمة مرور",
|
||||
"AUTH_FAILED": "فشلت المصادقة مع البوابة. تحقق من الرابط وعنوان MAC.",
|
||||
"LOGIN_REQUIRED": "تتطلب هذه البوابة اسم مستخدم وكلمة مرور. املأ حقلي اسم المستخدم وكلمة المرور وأعد المحاولة.",
|
||||
"LOGIN_REJECTED": "رفضت البوابة اسم المستخدم وكلمة المرور.",
|
||||
"PORTAL_REFUSED": "رفضت البوابة الوصول لهذا الجهاز.",
|
||||
"PORTAL_MESSAGE": "أفادت البوابة: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "التصفية حسب الاسم",
|
||||
"FILTER_AND_SORT": "تصفية وفرز",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "لم يتم تحديد فئة",
|
||||
"DESCRIPTION": "يرجى اختيار فئة لعرض المحتوى"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "تتطلب البوابة اسم مستخدم وكلمة مرور. أعد استيراد البوابة واملأ حقلي اسم المستخدم وكلمة المرور.",
|
||||
"PLAYLIST_SETTINGS": "إعدادات القائمة",
|
||||
"HOME": "الرئيسية",
|
||||
"DELETE": "حذف"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "رابط الخادم",
|
||||
"URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com/c ولا https://example.com/stalker_portal/c)",
|
||||
"ADD": "زيد",
|
||||
"VALIDATING": "جاري التحقق من البوابة..."
|
||||
"VALIDATING": "جاري التحقق من البوابة...",
|
||||
"CREDENTIALS_HINT": "خاصين غير إذا البوابة كتطلب اسم المستخدم وكلمة المرور",
|
||||
"AUTH_FAILED": "فشل تسجيل الدخول للبوابة. تحقق من الرابط وعنوان MAC.",
|
||||
"LOGIN_REQUIRED": "هاد البوابة كتطلب اسم المستخدم وكلمة المرور. عمّر خانات اسم المستخدم وكلمة المرور وحاول مرة أخرى.",
|
||||
"LOGIN_REJECTED": "البوابة رفضات اسم المستخدم وكلمة المرور.",
|
||||
"PORTAL_REFUSED": "البوابة رفضات الوصول لهاد الجهاز.",
|
||||
"PORTAL_MESSAGE": "البوابة قالت: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "فلتر بالاسم",
|
||||
"FILTER_AND_SORT": "فلتر وترتيب",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "ما كاين حتى فئة متختارة",
|
||||
"DESCRIPTION": "عفاك اختار فئة باش تشوف المحتوى"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "البوابة كتطلب اسم المستخدم وكلمة المرور. عاود استيراد البوابة وعمّر خانات اسم المستخدم وكلمة المرور.",
|
||||
"PLAYLIST_SETTINGS": "إعدادات قائمة التشغيل",
|
||||
"HOME": "الرئيسية",
|
||||
"DELETE": "حذف"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL сервера",
|
||||
"URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com/c або https://example.com/stalker_portal/c).",
|
||||
"ADD": "Дадаць",
|
||||
"VALIDATING": "Праверка партала..."
|
||||
"VALIDATING": "Праверка партала...",
|
||||
"CREDENTIALS_HINT": "Патрэбна толькі тады, калі партал запытвае лагін і пароль",
|
||||
"AUTH_FAILED": "Не ўдалося аўтэнтыфікавацца на партале. Праверце URL і MAC-адрас.",
|
||||
"LOGIN_REQUIRED": "Гэты партал патрабуе лагін і пароль. Запоўніце палі імя карыстальніка і пароля і паспрабуйце яшчэ раз.",
|
||||
"LOGIN_REJECTED": "Партал адхіліў лагін і пароль.",
|
||||
"PORTAL_REFUSED": "Партал адмовіў у доступе для гэтай прылады.",
|
||||
"PORTAL_MESSAGE": "Партал паведаміў: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Фільтраваць па імені",
|
||||
"FILTER_AND_SORT": "Фільтр і сартаванне",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Катэгорыя не выбрана",
|
||||
"DESCRIPTION": "Калі ласка, выберыце катэгорыю, каб убачыць кантэнт"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Партал патрабуе лагін і пароль. Імпартуйце партал яшчэ раз і запоўніце палі імя карыстальніка і пароля.",
|
||||
"PLAYLIST_SETTINGS": "Налады плэйліста",
|
||||
"HOME": "На галоўную",
|
||||
"DELETE": "Выдаліць"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Server-URL",
|
||||
"URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)",
|
||||
"ADD": "Hinzufügen",
|
||||
"VALIDATING": "Portal wird überprüft …"
|
||||
"VALIDATING": "Portal wird überprüft …",
|
||||
"CREDENTIALS_HINT": "Nur erforderlich, wenn das Portal Benutzername und Passwort verlangt",
|
||||
"AUTH_FAILED": "Authentifizierung am Portal fehlgeschlagen. Überprüfen Sie die URL und die MAC-Adresse.",
|
||||
"LOGIN_REQUIRED": "Dieses Portal erfordert Benutzername und Passwort. Füllen Sie die Felder Benutzername und Passwort aus und versuchen Sie es erneut.",
|
||||
"LOGIN_REJECTED": "Das Portal hat Benutzername und Passwort abgelehnt.",
|
||||
"PORTAL_REFUSED": "Das Portal hat den Zugriff für dieses Gerät verweigert.",
|
||||
"PORTAL_MESSAGE": "Das Portal meldet: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Nach Namen filtern",
|
||||
"FILTER_AND_SORT": "Filtern & Sortieren",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Keine Kategorie ausgewählt",
|
||||
"DESCRIPTION": "Bitte wählen Sie eine Kategorie aus, um den Inhalt anzuzeigen"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Das Portal erfordert Benutzername und Passwort. Importieren Sie das Portal erneut und füllen Sie die Felder Benutzername und Passwort aus.",
|
||||
"PLAYLIST_SETTINGS": "Playlist-Einstellungen",
|
||||
"HOME": "Startseite",
|
||||
"DELETE": "Löschen"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Διεύθηνση URL σέρβερ",
|
||||
"URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com/c ή https://example.com/stalker_portal/c)",
|
||||
"ADD": "Προσθήκη",
|
||||
"VALIDATING": "Επικύρωση πύλης..."
|
||||
"VALIDATING": "Επικύρωση πύλης...",
|
||||
"CREDENTIALS_HINT": "Απαιτείται μόνο όταν η πύλη ζητά όνομα χρήστη και κωδικό πρόσβασης",
|
||||
"AUTH_FAILED": "Η ταυτοποίηση με την πύλη απέτυχε. Ελέγξτε τη διεύθυνση URL και τη διεύθυνση MAC.",
|
||||
"LOGIN_REQUIRED": "Αυτή η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης και δοκιμάστε ξανά.",
|
||||
"LOGIN_REJECTED": "Η πύλη απέρριψε το όνομα χρήστη και τον κωδικό πρόσβασης.",
|
||||
"PORTAL_REFUSED": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.",
|
||||
"PORTAL_MESSAGE": "Η πύλη ανέφερε: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα",
|
||||
"FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Δεν έχει επιλεγεί κατηγορία",
|
||||
"DESCRIPTION": "Επιλέξτε μια κατηγορία για να δείτε το περιεχόμενο"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Εισαγάγετε ξανά την πύλη και συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης.",
|
||||
"PLAYLIST_SETTINGS": "Ρυθμίσεις λίστας αναπαραγωγής",
|
||||
"HOME": "Αρχικό",
|
||||
"DELETE": "Διαγραφή"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Server URL",
|
||||
"URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com/c or https://example.com/stalker_portal/c)",
|
||||
"ADD": "Add",
|
||||
"VALIDATING": "Validating portal..."
|
||||
"VALIDATING": "Validating portal...",
|
||||
"CREDENTIALS_HINT": "Only needed when the portal asks for a login and password",
|
||||
"AUTH_FAILED": "Failed to authenticate with the portal. Check the URL and MAC address.",
|
||||
"LOGIN_REQUIRED": "This portal requires a login and password. Fill in the username and password fields and try again.",
|
||||
"LOGIN_REJECTED": "The portal rejected the login and password.",
|
||||
"PORTAL_REFUSED": "The portal refused access for this device.",
|
||||
"PORTAL_MESSAGE": "The portal reported: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filter by name",
|
||||
"FILTER_AND_SORT": "Filter & Sort",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "No category selected",
|
||||
"DESCRIPTION": "Please select a category to see the content"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "The portal requires a login and password. Import the portal again and fill in the username and password fields.",
|
||||
"PLAYLIST_SETTINGS": "Playlist Settings",
|
||||
"HOME": "Home",
|
||||
"DELETE": "Delete"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL del servidor",
|
||||
"URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com/c o https://example.com/stalker_portal/c).",
|
||||
"ADD": "Agregar",
|
||||
"VALIDATING": "Validando portal…"
|
||||
"VALIDATING": "Validando portal…",
|
||||
"CREDENTIALS_HINT": "Solo es necesario cuando el portal pide usuario y contraseña",
|
||||
"AUTH_FAILED": "No se pudo autenticar con el portal. Verifica la URL y la dirección MAC.",
|
||||
"LOGIN_REQUIRED": "Este portal requiere usuario y contraseña. Completa los campos de nombre de usuario y contraseña e inténtalo de nuevo.",
|
||||
"LOGIN_REJECTED": "El portal rechazó el usuario y la contraseña.",
|
||||
"PORTAL_REFUSED": "El portal denegó el acceso a este dispositivo.",
|
||||
"PORTAL_MESSAGE": "El portal informó: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrar por nombre",
|
||||
"FILTER_AND_SORT": "Filtrar y ordenar",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Ninguna categoría seleccionada",
|
||||
"DESCRIPTION": "Selecciona una categoría para ver el contenido"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "El portal requiere usuario y contraseña. Vuelve a importar el portal y completa los campos de nombre de usuario y contraseña.",
|
||||
"PLAYLIST_SETTINGS": "Configuración de lista de reproducción",
|
||||
"HOME": "Hogar",
|
||||
"DELETE": "Borrar"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL du serveur",
|
||||
"URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com/c ou https://example.com/stalker_portal/c)",
|
||||
"ADD": "Ajouter",
|
||||
"VALIDATING": "Validation du portail…"
|
||||
"VALIDATING": "Validation du portail…",
|
||||
"CREDENTIALS_HINT": "Nécessaire uniquement lorsque le portail demande un identifiant et un mot de passe",
|
||||
"AUTH_FAILED": "Échec de l'authentification auprès du portail. Vérifiez l'URL et l'adresse MAC.",
|
||||
"LOGIN_REQUIRED": "Ce portail nécessite un identifiant et un mot de passe. Renseignez les champs nom d'utilisateur et mot de passe, puis réessayez.",
|
||||
"LOGIN_REJECTED": "Le portail a rejeté l'identifiant et le mot de passe.",
|
||||
"PORTAL_REFUSED": "Le portail a refusé l'accès pour cet appareil.",
|
||||
"PORTAL_MESSAGE": "Le portail a signalé : {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrer par nom",
|
||||
"FILTER_AND_SORT": "Filtrer et trier",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Aucune catégorie sélectionnée",
|
||||
"DESCRIPTION": "Veuillez sélectionner une catégorie pour voir le contenu"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Le portail nécessite un identifiant et un mot de passe. Importez à nouveau le portail et renseignez les champs nom d'utilisateur et mot de passe.",
|
||||
"PLAYLIST_SETTINGS": "Paramètres de la liste",
|
||||
"HOME": "Accueil",
|
||||
"DELETE": "Supprimer"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Kiszolgáló URL-címe",
|
||||
"URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com/c vagy https://example.com/stalker_portal/c)",
|
||||
"ADD": "Hozzáadás",
|
||||
"VALIDATING": "Portál ellenőrzése…"
|
||||
"VALIDATING": "Portál ellenőrzése…",
|
||||
"CREDENTIALS_HINT": "Csak akkor szükséges, ha a portál felhasználónevet és jelszót kér",
|
||||
"AUTH_FAILED": "Nem sikerült a hitelesítés a portálon. Ellenőrizze az URL-címet és a MAC-címet.",
|
||||
"LOGIN_REQUIRED": "Ez a portál felhasználónevet és jelszót igényel. Töltse ki a felhasználónév és a jelszó mezőt, majd próbálja újra.",
|
||||
"LOGIN_REJECTED": "A portál elutasította a felhasználónevet és a jelszót.",
|
||||
"PORTAL_REFUSED": "A portál megtagadta a hozzáférést ettől az eszköztől.",
|
||||
"PORTAL_MESSAGE": "A portál a következőt jelezte: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Szűrés név alapján",
|
||||
"FILTER_AND_SORT": "Szűrés és rendezés",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Nincs kiválasztott kategória",
|
||||
"DESCRIPTION": "A tartalom megjelenítéséhez válasszon egy kategóriát."
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "A portál felhasználónevet és jelszót igényel. Importálja újra a portált, és töltse ki a felhasználónév és a jelszó mezőt.",
|
||||
"PLAYLIST_SETTINGS": "Lejátszási lista beállításai",
|
||||
"HOME": "Kezdőlap",
|
||||
"DELETE": "Törlés"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL server",
|
||||
"URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it/c o https://esempio.it/stalker_portal/c)",
|
||||
"ADD": "Aggiungi",
|
||||
"VALIDATING": "Validazione del portale..."
|
||||
"VALIDATING": "Validazione del portale...",
|
||||
"CREDENTIALS_HINT": "Necessario solo se il portale richiede nome utente e password",
|
||||
"AUTH_FAILED": "Impossibile autenticarsi con il portale. Controlla l'URL e l'indirizzo MAC.",
|
||||
"LOGIN_REQUIRED": "Questo portale richiede nome utente e password. Compila i campi nome utente e password e riprova.",
|
||||
"LOGIN_REJECTED": "Il portale ha rifiutato il nome utente e la password.",
|
||||
"PORTAL_REFUSED": "Il portale ha negato l'accesso a questo dispositivo.",
|
||||
"PORTAL_MESSAGE": "Il portale ha segnalato: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtra per nome",
|
||||
"FILTER_AND_SORT": "Filtra e ordina",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Nessuna categoria selezionata",
|
||||
"DESCRIPTION": "Seleziona una categoria per vedere i contenuti"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Il portale richiede nome utente e password. Importa di nuovo il portale e compila i campi nome utente e password.",
|
||||
"PLAYLIST_SETTINGS": "Impostazioni Playlist",
|
||||
"HOME": "Home",
|
||||
"DELETE": "Elimina"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "サーバーURL",
|
||||
"URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com/c または https://example.com/stalker_portal/c)",
|
||||
"ADD": "追加",
|
||||
"VALIDATING": "ポータルを検証中..."
|
||||
"VALIDATING": "ポータルを検証中...",
|
||||
"CREDENTIALS_HINT": "ポータルがユーザー名とパスワードを要求する場合のみ必要",
|
||||
"AUTH_FAILED": "ポータルでの認証に失敗しました。URLとMACアドレスを確認してください。",
|
||||
"LOGIN_REQUIRED": "このポータルにはユーザー名とパスワードが必要です。ユーザー名とパスワードの欄を入力して、もう一度お試しください。",
|
||||
"LOGIN_REJECTED": "ポータルがユーザー名とパスワードを拒否しました。",
|
||||
"PORTAL_REFUSED": "ポータルがこのデバイスのアクセスを拒否しました。",
|
||||
"PORTAL_MESSAGE": "ポータルからの報告:{{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "名前でフィルター",
|
||||
"FILTER_AND_SORT": "フィルターと並び替え",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "カテゴリーが選択されていません",
|
||||
"DESCRIPTION": "コンテンツを表示するにはカテゴリーを選択してください"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "ポータルにはユーザー名とパスワードが必要です。ポータルを再インポートして、ユーザー名とパスワードの欄を入力してください。",
|
||||
"PLAYLIST_SETTINGS": "プレイリスト設定",
|
||||
"HOME": "ホーム",
|
||||
"DELETE": "削除"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "서버 URL",
|
||||
"URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com/c 또는 https://example.com/stalker_portal/c).",
|
||||
"ADD": "추가하다",
|
||||
"VALIDATING": "포털을 검증하는 중..."
|
||||
"VALIDATING": "포털을 검증하는 중...",
|
||||
"CREDENTIALS_HINT": "포털이 사용자 이름과 비밀번호를 요구하는 경우에만 필요",
|
||||
"AUTH_FAILED": "포털 인증에 실패했습니다. URL과 MAC 주소를 확인하세요.",
|
||||
"LOGIN_REQUIRED": "이 포털에는 사용자 이름과 비밀번호가 필요합니다. 사용자 이름과 비밀번호 필드를 입력한 후 다시 시도하세요.",
|
||||
"LOGIN_REJECTED": "포털이 사용자 이름과 비밀번호를 거부했습니다.",
|
||||
"PORTAL_REFUSED": "포털이 이 기기의 접근을 거부했습니다.",
|
||||
"PORTAL_MESSAGE": "포털에서 보고한 내용: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "이름으로 필터",
|
||||
"FILTER_AND_SORT": "필터 및 정렬",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "선택된 카테고리가 없습니다",
|
||||
"DESCRIPTION": "콘텐츠를 보려면 카테고리를 선택하세요"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "포털에 사용자 이름과 비밀번호가 필요합니다. 포털을 다시 가져온 후 사용자 이름과 비밀번호 필드를 입력하세요.",
|
||||
"PLAYLIST_SETTINGS": "재생목록 설정",
|
||||
"HOME": "홈",
|
||||
"DELETE": "삭제"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Server URL",
|
||||
"URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com/c o https://example.com/stalker_portal/c)",
|
||||
"ADD": "Toevoegen",
|
||||
"VALIDATING": "Portaal valideren..."
|
||||
"VALIDATING": "Portaal valideren...",
|
||||
"CREDENTIALS_HINT": "Alleen nodig als het portaal om een gebruikersnaam en wachtwoord vraagt",
|
||||
"AUTH_FAILED": "Aanmelden bij het portaal is mislukt. Controleer de URL en het MAC-adres.",
|
||||
"LOGIN_REQUIRED": "Dit portaal vereist een gebruikersnaam en wachtwoord. Vul de velden gebruikersnaam en wachtwoord in en probeer het opnieuw.",
|
||||
"LOGIN_REJECTED": "Het portaal heeft de gebruikersnaam en het wachtwoord geweigerd.",
|
||||
"PORTAL_REFUSED": "Het portaal heeft de toegang voor dit apparaat geweigerd.",
|
||||
"PORTAL_MESSAGE": "Het portaal meldde: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filter op naam",
|
||||
"FILTER_AND_SORT": "Filteren & sorteren",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Geen categorie geselecteerd",
|
||||
"DESCRIPTION": "Selecteer een categorie om de inhoud te bekijken"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Het portaal vereist een gebruikersnaam en wachtwoord. Importeer het portaal opnieuw en vul de velden gebruikersnaam en wachtwoord in.",
|
||||
"PLAYLIST_SETTINGS": "Afspeellijst instellingen",
|
||||
"HOME": "Home",
|
||||
"DELETE": "Verwijderen"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL serwera",
|
||||
"URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com/c lub https://example.com/stalker_portal/c)",
|
||||
"ADD": "Dodaj",
|
||||
"VALIDATING": "Walidacja portalu..."
|
||||
"VALIDATING": "Walidacja portalu...",
|
||||
"CREDENTIALS_HINT": "Potrzebne tylko wtedy, gdy portal wymaga loginu i hasła",
|
||||
"AUTH_FAILED": "Nie udało się uwierzytelnić w portalu. Sprawdź URL i adres MAC.",
|
||||
"LOGIN_REQUIRED": "Ten portal wymaga loginu i hasła. Wypełnij pola nazwy użytkownika i hasła i spróbuj ponownie.",
|
||||
"LOGIN_REJECTED": "Portal odrzucił login i hasło.",
|
||||
"PORTAL_REFUSED": "Portal odmówił dostępu temu urządzeniu.",
|
||||
"PORTAL_MESSAGE": "Portal zgłosił: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtruj według nazwy",
|
||||
"FILTER_AND_SORT": "Filtruj i sortuj",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Nie wybrano kategorii",
|
||||
"DESCRIPTION": "Wybierz kategorię, aby zobaczyć treści"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Portal wymaga loginu i hasła. Zaimportuj portal ponownie i wypełnij pola nazwy użytkownika i hasła.",
|
||||
"PLAYLIST_SETTINGS": "Ustawienia listy odtwarzania",
|
||||
"HOME": "Strona główna",
|
||||
"DELETE": "Usuń"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL do servidor",
|
||||
"URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com/c ou https://example.com/stalker_portal/c)",
|
||||
"ADD": "Adicionar",
|
||||
"VALIDATING": "Validando portal..."
|
||||
"VALIDATING": "Validando portal...",
|
||||
"CREDENTIALS_HINT": "Necessário apenas quando o portal exige login e senha",
|
||||
"AUTH_FAILED": "Falha na autenticação com o portal. Verifique a URL e o endereço MAC.",
|
||||
"LOGIN_REQUIRED": "Este portal exige login e senha. Preencha os campos de nome de usuário e senha e tente novamente.",
|
||||
"LOGIN_REJECTED": "O portal rejeitou o login e a senha.",
|
||||
"PORTAL_REFUSED": "O portal recusou o acesso para este dispositivo.",
|
||||
"PORTAL_MESSAGE": "O portal informou: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrar por nome",
|
||||
"FILTER_AND_SORT": "Filtrar e ordenar",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Nenhuma categoria selecionada",
|
||||
"DESCRIPTION": "Selecione uma categoria para ver o conteúdo"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "O portal exige login e senha. Importe o portal novamente e preencha os campos de nome de usuário e senha.",
|
||||
"PLAYLIST_SETTINGS": "Configurações da playlist",
|
||||
"HOME": "Início",
|
||||
"DELETE": "Excluir"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "URL сервера",
|
||||
"URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c).",
|
||||
"ADD": "Добавить",
|
||||
"VALIDATING": "Проверка портала…"
|
||||
"VALIDATING": "Проверка портала…",
|
||||
"CREDENTIALS_HINT": "Требуется, только если портал запрашивает логин и пароль",
|
||||
"AUTH_FAILED": "Не удалось авторизоваться на портале. Проверьте URL и MAC-адрес.",
|
||||
"LOGIN_REQUIRED": "Этот портал требует логин и пароль. Заполните поля имени пользователя и пароля и повторите попытку.",
|
||||
"LOGIN_REJECTED": "Портал отклонил логин и пароль.",
|
||||
"PORTAL_REFUSED": "Портал отказал в доступе этому устройству.",
|
||||
"PORTAL_MESSAGE": "Портал сообщил: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "Фильтровать по имени",
|
||||
"FILTER_AND_SORT": "Фильтр и сортировка",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Категория не выбрана",
|
||||
"DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Портал требует логин и пароль. Импортируйте портал заново и заполните поля имени пользователя и пароля.",
|
||||
"PLAYLIST_SETTINGS": "Настройки плейлиста",
|
||||
"HOME": "На главную",
|
||||
"DELETE": "Удалить"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "Sunucu URL'si",
|
||||
"URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com/c veya https://example.com/stalker_portal/c)",
|
||||
"ADD": "Ekle",
|
||||
"VALIDATING": "Portal doğrulanıyor..."
|
||||
"VALIDATING": "Portal doğrulanıyor...",
|
||||
"CREDENTIALS_HINT": "Yalnızca portal kullanıcı adı ve parola istediğinde gereklidir",
|
||||
"AUTH_FAILED": "Portalda kimlik doğrulaması başarısız oldu. URL'yi ve MAC adresini kontrol edin.",
|
||||
"LOGIN_REQUIRED": "Bu portal kullanıcı adı ve parola gerektiriyor. Kullanıcı adı ve parola alanlarını doldurup tekrar deneyin.",
|
||||
"LOGIN_REJECTED": "Portal, kullanıcı adı ve parolayı reddetti.",
|
||||
"PORTAL_REFUSED": "Portal bu cihaz için erişimi reddetti.",
|
||||
"PORTAL_MESSAGE": "Portal şunu bildirdi: {{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "İsime göre filtrele",
|
||||
"FILTER_AND_SORT": "Filtrele ve Sırala",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "Kategori seçilmedi",
|
||||
"DESCRIPTION": "İçeriği görmek için lütfen bir kategori seçin"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "Portal, kullanıcı adı ve parola gerektiriyor. Portalı yeniden içe aktarın ve kullanıcı adı ile parola alanlarını doldurun.",
|
||||
"PLAYLIST_SETTINGS": "Oynatma Listesi Ayarları",
|
||||
"HOME": "Ana Sayfa",
|
||||
"DELETE": "Sil"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "服务器 URL",
|
||||
"URL_VALIDATION_ERROR": "应该是带有协议的有效网址(例如 http://example.com/c 或 https://example.com/stalker_portal/c)",
|
||||
"ADD": "添加",
|
||||
"VALIDATING": "正在验证门户…"
|
||||
"VALIDATING": "正在验证门户…",
|
||||
"CREDENTIALS_HINT": "仅在门户要求登录名和密码时才需要填写",
|
||||
"AUTH_FAILED": "门户身份验证失败。请检查 URL 和 MAC 地址。",
|
||||
"LOGIN_REQUIRED": "此门户需要登录名和密码。请填写用户名和密码字段后重试。",
|
||||
"LOGIN_REJECTED": "门户拒绝了该登录名和密码。",
|
||||
"PORTAL_REFUSED": "门户拒绝了此设备的访问。",
|
||||
"PORTAL_MESSAGE": "门户返回信息:{{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "按名称筛选",
|
||||
"FILTER_AND_SORT": "筛选与排序",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "未选择分类",
|
||||
"DESCRIPTION": "请选择一个分类以查看内容"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "门户需要登录名和密码。请重新导入该门户并填写用户名和密码字段。",
|
||||
"PLAYLIST_SETTINGS": "播放列表设置",
|
||||
"HOME": "首页",
|
||||
"DELETE": "删除"
|
||||
|
||||
@@ -195,7 +195,13 @@
|
||||
"SERVER_URL": "伺服器網址",
|
||||
"URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com/c 或 https://example.com/stalker_portal/c)",
|
||||
"ADD": "新增",
|
||||
"VALIDATING": "正在驗證入口網站..."
|
||||
"VALIDATING": "正在驗證入口網站...",
|
||||
"CREDENTIALS_HINT": "僅在入口網站要求登入名稱與密碼時才需填寫",
|
||||
"AUTH_FAILED": "入口網站驗證失敗。請檢查網址與 MAC 位址。",
|
||||
"LOGIN_REQUIRED": "此入口網站需要登入名稱與密碼。請填寫使用者名稱與密碼欄位後重試。",
|
||||
"LOGIN_REJECTED": "入口網站拒絕了此登入名稱與密碼。",
|
||||
"PORTAL_REFUSED": "入口網站拒絕了此裝置的存取。",
|
||||
"PORTAL_MESSAGE": "入口網站回報:{{message}}"
|
||||
},
|
||||
"FILTER_BY_NAME": "依名稱篩選",
|
||||
"FILTER_AND_SORT": "篩選與排序",
|
||||
@@ -957,6 +963,7 @@
|
||||
"TITLE": "未選擇類別",
|
||||
"DESCRIPTION": "請選擇一個類別以查看內容"
|
||||
},
|
||||
"STALKER_LOGIN_REQUIRED": "入口網站需要登入名稱與密碼。請重新匯入入口網站並填寫使用者名稱與密碼欄位。",
|
||||
"PLAYLIST_SETTINGS": "播放清單設定",
|
||||
"HOME": "首頁",
|
||||
"DELETE": "刪除"
|
||||
|
||||
@@ -297,6 +297,9 @@ interface ScenarioConfig {
|
||||
supportsGetAllChannels?: boolean; // default true; false mimics legacy portals
|
||||
// without the ITV get_all_channels action
|
||||
marketingFixture?: true; // replace generated VOD with shared posters
|
||||
requiresLogin?: true; // get_profile answers status 2 until do_auth
|
||||
gatedStream?: true; // create_link returns a credential-gated URL
|
||||
staticChannelCmd?: true; // ITV rows need no temporary link
|
||||
}
|
||||
```
|
||||
|
||||
@@ -317,6 +320,16 @@ forwarded host/protocol) as
|
||||
`<portal-origin>/assets/marketing/poster/<slug>.png`. `main.ts` serves the
|
||||
committed PNG directory directly, so the Xtream server does not need to run.
|
||||
|
||||
Every generated ITV channel and radio station carries `use_http_tmp_link` and
|
||||
`use_load_balancing`, the flags a real portal uses to tell a client whether the
|
||||
row needs `create_link`. They are `'1'`/`'0'` for the default generators —
|
||||
honest, because those rows carry `ffrt4://…` pseudo-URLs. The
|
||||
`static-channel-cmd` scenario (`00:1A:79:00:00:0A`) sets `staticChannelCmd`,
|
||||
which gives ITV rows both flags at `'0'` and a real
|
||||
`ffrt3 https://…m3u8` command, so `apps/web-e2e/src/stalker.e2e.ts` can assert
|
||||
that no `create_link` request reaches the portal. See
|
||||
`docs/architecture/stalker-portal.md`, "Playback Link Resolution".
|
||||
|
||||
### Adding a New Scenario
|
||||
|
||||
1. Add an entry to the `SCENARIOS` map in `src/app/scenarios.ts`.
|
||||
|
||||
@@ -83,9 +83,9 @@ Two portal modes exist, persisted per playlist as
|
||||
the wider phrase set (`Invalid token`, `Auth failed`, bare `unauthorized`),
|
||||
since a panel fills those in deliberately. While a full portal is the
|
||||
active playlist, `StalkerSessionService` keeps a **watchdog** running —
|
||||
periodic authenticated `watchdog/get_events` pings (currently every 25 s;
|
||||
the protocol default expects 120 s, tracked for a later PR) whose failures
|
||||
are non-fatal.
|
||||
periodic authenticated `watchdog/get_events` pings at the cadence the
|
||||
portal advertises (`watchdog_timeout`, default 120 s — see "Watchdog"
|
||||
below) whose failures are non-fatal.
|
||||
- **Simple portal** (reseller-style `portal.php` panels): no auth lifecycle
|
||||
at all — requests carry only the `mac=` cookie.
|
||||
|
||||
@@ -235,6 +235,181 @@ blank fields are not generated or forwarded to `get_profile`.
|
||||
metadata is independent from M3U playlist EPG metadata and must not depend on
|
||||
M3U-specific EPG fields.
|
||||
|
||||
## Session Authentication Lifecycle
|
||||
|
||||
Full portals authenticate through `StalkerSessionService`
|
||||
(`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), which
|
||||
is a facade over three focused modules:
|
||||
|
||||
- `stalker-auth.api.ts` — the raw `handshake` / `get_profile` / `do_auth`
|
||||
requests and the `authenticate()` orchestration.
|
||||
- `stalker-watchdog.controller.ts` — the periodic `get_events` keep-alive.
|
||||
- `stalker-token-cache.ts` — the in-run token and pending-auth state, tagged
|
||||
with the identity fingerprint each session was negotiated for.
|
||||
- `stalker-session-store.ts` — the session persisted on the playlist row.
|
||||
- `stalker-response-classification.ts` + `stalker-portal-error.ts` — failure
|
||||
detection and the typed `StalkerPortalError` the UI layers render.
|
||||
|
||||
### Handshake and token persistence
|
||||
|
||||
The handshake token is **idempotent** (Stalker 4.9.35 `stb.class.php`):
|
||||
re-presenting the MAC's current session token returns it unchanged, and tokens
|
||||
have no TTL — they are only invalidated when another device runs `get_profile`
|
||||
on the same MAC. `ensureToken()` exploits this: when the in-memory cache is
|
||||
cold it re-presents the persisted `Playlist.stalkerToken` (from the playlist
|
||||
object, falling back to the stored row via `PlaylistsService`, since store
|
||||
metas do not carry payload fields). A token that comes back unchanged is an
|
||||
already-adopted session, so the `get_profile` round trip is skipped entirely —
|
||||
unless the handshake also set `not_valid`, which vetoes the shortcut: adopting
|
||||
a token the portal just called dead would be unrecoverable, since the reuse
|
||||
path writes no replacement back and every later start would re-present it.
|
||||
A renegotiated session is written back best-effort
|
||||
(`PlaylistsService.updateStalkerSession`) so the next app start can reuse it.
|
||||
The handshake's `not_valid` flag is propagated into the follow-up
|
||||
`get_profile` as `not_valid_token`.
|
||||
|
||||
Reuse is gated on a session fingerprint (`stalkerSessionFingerprint`) covering
|
||||
the **portal endpoint (origin AND path), the device identity and the account
|
||||
credentials**, stored
|
||||
next to the token as `Playlist.stalkerSessionIdentity` and used for the
|
||||
in-run cache as well, so an edit applies without a restart. All three halves
|
||||
are load-bearing: `ensureToken()` re-presents tokens in a handshake, so an
|
||||
endpoint edit would otherwise disclose the previous portal's bearer token to
|
||||
another portal — and origin alone is not enough, since discovery deliberately
|
||||
preserves tenant base paths, so `/tenant-a/…` and `/tenant-b/…` on one host
|
||||
are different portals; an identity edit must not inherit the old session; and for a
|
||||
status-2 portal the login decides which account the token represents. A token
|
||||
with no recorded fingerprint (written before this existed) counts as
|
||||
unverified and is never re-presented — such a row owes a full profile anyway,
|
||||
and the write-back then records the fingerprint.
|
||||
|
||||
Because that reuse skips the only response carrying the watchdog cadence, the
|
||||
cadence is persisted **with** the token (`Playlist.stalkerWatchdogTimeout` /
|
||||
`stalkerTimeslot`, payload fields like `stalkerToken` itself — no schema
|
||||
change) and re-applied on the reuse path. The import dialog persists what its
|
||||
own `get_profile` advertised, which for a portal whose token never goes stale
|
||||
is the only profile the app ever sees.
|
||||
|
||||
The skip is therefore conditional on the cadence being **known**: a playlist
|
||||
imported before the cadence was persisted has a reusable token and no
|
||||
cadence, and skipping would strand it on the 120 s default permanently, since
|
||||
the profile is the only thing that could teach it. Such a playlist runs one
|
||||
profile, persists what it learns, and skips from then on. What gets persisted
|
||||
is the *effective* cadence (the 120 s default when the portal advertises
|
||||
none), so stored absence keeps meaning exactly one thing — never profiled —
|
||||
rather than sending a portal that advertises nothing back through a profile on
|
||||
every start.
|
||||
|
||||
### `get_profile` status decoding
|
||||
|
||||
`authenticate()` decodes `js.status` the way the stock middleware means it:
|
||||
|
||||
- full profile / `status: 0` — OK; `watchdog_timeout` and `timeslot` are read
|
||||
for the watchdog cadence.
|
||||
- `status: 1` — blocked (device conflict, malformed MAC, disabled account).
|
||||
`msg`/`block_msg` carry the portal's own explanation; they are
|
||||
markup-stripped, combined, and thrown as `StalkerPortalError('blocked')`.
|
||||
- `status: 2` — login/password required. The client runs `do_auth`
|
||||
(`login`, `password`, plus `device_id`/`device_id2` when configured) and
|
||||
retries `get_profile` with `auth_second_step=1`. Only that retry claims the
|
||||
second auth step — the initial request sends `auth_second_step=0`. Missing
|
||||
credentials throw `StalkerPortalError('login-required')`; a `{js: false}`
|
||||
verdict (the operator billing script refused) throws `'login-rejected'`.
|
||||
|
||||
Credentials come from the import dialog's username/password fields and are
|
||||
persisted on the playlist, so runtime re-authentication can repeat `do_auth`
|
||||
after the portal drops the session.
|
||||
|
||||
### Plain-text failure bodies
|
||||
|
||||
Auth failures are **HTTP 200 + a text/html body**, never a 401/403. The three
|
||||
exact bodies (`Authorization failed.` — stale/missing token, optionally with a
|
||||
numeric debug suffix; `Access denied.` — blocked account;
|
||||
`Unauthorized request.` — missing mac cookie) are classified at the transport
|
||||
boundary: the Electron main process
|
||||
(`apps/electron-backend/src/app/events/stalker.events.ts`) converts them into
|
||||
a structured `{ stalkerAuthFailure }` marker
|
||||
(`libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`) — returned,
|
||||
not thrown, because `ipcRenderer.invoke` strips custom properties from
|
||||
rejections. The PWA proxy path still delivers the raw string; the renderer
|
||||
classifier accepts both shapes plus the legacy `{ js: '<body>' }` envelope.
|
||||
`makeAuthenticatedRequest` retries once with fresh authentication and
|
||||
otherwise throws `StalkerPortalError('auth-failed')` carrying the body.
|
||||
|
||||
### Error surfacing
|
||||
|
||||
`StalkerPortalError.portalText` holds the portal's own words. The import
|
||||
dialog shows them in its failure snackbar (with kind-specific i18n headlines,
|
||||
`HOME.STALKER_PORTAL.*`); the workspace context panel replaces the generic
|
||||
"could not load categories" hint with the portal text (or the login-required
|
||||
guidance) when category loading failed with a portal refusal
|
||||
(`stalkerCategoryErrorDescription` in `workspace-context-panel.component.ts`).
|
||||
|
||||
Both renderers are kind-agnostic — they append `portalText` whenever it is
|
||||
present — so the obligation sits entirely on the throw sites: **every** exit
|
||||
out of the status-2 branch carries the text, not just the terminal `blocked`
|
||||
one. A login refusal is precisely where the portal says something actionable
|
||||
("wrong password", "subscription expired"), and dropping it leaves the user
|
||||
with a generic line while the useful sentence sits unread in the payload.
|
||||
Each exit reads the response IN HAND: after the `auth_second_step=1` retry the
|
||||
text is the retry's, since quoting the first profile back would describe a
|
||||
request that already succeeded. `do_auth` itself answers a bare `{js: false}`,
|
||||
so a rejection there keeps the profile's text — the one that asked for the
|
||||
login.
|
||||
|
||||
### Abandoning an authentication
|
||||
|
||||
`authenticate()` takes an optional `AbortSignal` and checks it before every
|
||||
portal call. Endpoint discovery gives each confirmation attempt its own
|
||||
controller and aborts it when the attempt exceeds its budget, before moving to
|
||||
the next candidate.
|
||||
|
||||
This matters because `get_profile` — not the handshake — is what adopts a
|
||||
token for the MAC portal-side. Without the check, a timed-out attempt could
|
||||
still send its `get_profile` after a later candidate had authenticated,
|
||||
invalidating that healthy candidate's token and making discovery report a
|
||||
working portal as refused.
|
||||
|
||||
Cancellation is deliberately cooperative rather than a socket-level abort
|
||||
threaded to axios: once a request is on the wire the server processes it
|
||||
regardless of what the client does, so tearing the socket down would not
|
||||
prevent the adoption. Only not sending the request does, which is exactly what
|
||||
the between-calls check guarantees.
|
||||
|
||||
That leaves the window where the timer fires while a `get_profile` is already
|
||||
dispatched — which the check cannot cover, but sequencing can: discovery
|
||||
**drains** the abandoned attempt (bounded by one request budget) before probing
|
||||
the next candidate, instead of racing it. The request cannot be un-sent, but
|
||||
nothing forces us to have a competing session in flight while it lands.
|
||||
|
||||
**A drain that times out stops discovery.** Draining is bounded, and the bound
|
||||
has to mean something: an attempt still unsettled after its own 65 s budget
|
||||
plus the 15 s drain is one no transport can recall — the PWA `fetch()` takes no
|
||||
signal at all, and the Electron main process runs its HTTP request to
|
||||
completion. Advancing anyway would stake the next candidate's freshly issued
|
||||
session on that request never landing. So the rejection carries
|
||||
`abandonedInFlight` and the candidate loop returns instead of probing on,
|
||||
preferring an honest "could not confirm this portal" the user can retry over a
|
||||
session that looks established and dies later. It costs nothing in the normal
|
||||
case: an aborted attempt settles as soon as its in-flight request errors out,
|
||||
so the drain returns at once and the loop continues.
|
||||
|
||||
The budget itself covers the longest real flow: a status-2 portal costs four
|
||||
sequential requests (handshake, profile, `do_auth`, profile retry) and the
|
||||
Electron transport allows each 15 s, so a two-request budget would have failed
|
||||
valid but slow login portals.
|
||||
|
||||
### Watchdog
|
||||
|
||||
The portal expects `get_events` every `watchdog_timeout` seconds — **120 by
|
||||
default**, echoed in the profile together with a per-user `timeslot` jitter
|
||||
that offsets the first periodic ping. `StalkerWatchdogController` starts with
|
||||
an immediate `init=1` ping on activation, applies the profile cadence when a
|
||||
profile is decoded (clamped to 30–3600 s against garbage), and otherwise uses
|
||||
the documented 120 s default. Failing to ping never invalidates the session —
|
||||
it only affects the portal's admin-panel "online" reporting — so ping failures
|
||||
are logged and never retried or escalated.
|
||||
|
||||
## Request Transport and `cmd` Encoding
|
||||
|
||||
A real MAG/STB sends `cmd` unencoded: the portal's client JS concatenates raw
|
||||
@@ -305,6 +480,232 @@ the cross-portal collection resolver (`StreamResolverService`) use
|
||||
resolve relative (`/media/...`) or query-only (`?token=...`) `create_link`
|
||||
replies against the portal base URL.
|
||||
|
||||
## Playback Link Resolution
|
||||
|
||||
### When `create_link` is called
|
||||
|
||||
A Stalker catalog row decides for itself whether it needs a temporary link.
|
||||
The portal's own `player.js` — mirrored by Kodi's `pvr.stalker` — calls
|
||||
`create_link` only when the row sets `use_http_tmp_link` (the portal proxies
|
||||
the stream through a per-session URL) or `use_load_balancing` (the portal
|
||||
picks a storage server per request). Every other row plays the static `cmd`
|
||||
that `get_all_channels` / `get_ordered_list` already returned. Until PR 8 the
|
||||
app called `create_link` unconditionally, so every playback paid a round trip
|
||||
and gained a failure point that the reference client does not have.
|
||||
|
||||
One helper owns the decision:
|
||||
`resolveStalkerStaticPlaybackUrl(row, cmd)` in
|
||||
`libs/portal/stalker/data-access/src/lib/stores/utils/stalker-link-semantics.utils.ts`.
|
||||
It returns the playable URL when the static path applies and `null` when the
|
||||
portal has to resolve the command. `null` is deliberately wider than the flag
|
||||
check alone; the extra guards can only push a row back onto the `create_link`
|
||||
path, so they cannot regress a portal that works today:
|
||||
|
||||
| Input | Verdict | Why |
|
||||
| --- | --- | --- |
|
||||
| Either flag truthy (`1`, `'1'`, `true`) | `create_link` | The portal asked for a temporary link. |
|
||||
| No row supplied at all | `create_link` | A caller that cannot show the flags gets no verdict. |
|
||||
| A row carrying neither flag KEY | `create_link` | Absence means "no evidence", not "no". A stock portal returns both flags on every row, so their PRESENCE is the provenance signal — and the only one available, because rows persisted into Favorites/Recently Viewed before this change were stripped of them by `buildStalkerSelectedVodItem`'s whitelist, making a legacy snapshot indistinguishable from a genuinely unflagged row. There is no migration for those. **Radio is the documented exception**: a directly playable radio command has always played as-is, so a flagless radio row keeps that rather than newly minting. |
|
||||
| Relative `/media/file_12.mpg` or query-only `?token=…` | `create_link` | Only the portal turns those into an address; the VOD `has_files` rewrite produces exactly the first shape. |
|
||||
| Non-HTTP scheme (`ffrt4://ch/live/…`) | `create_link` | Portal-internal pseudo-URL. |
|
||||
| Portal-local host — `localhost` and any `*.localhost` name (RFC 6761 §6.3 reserves the whole suffix for loopback), `localhost.localdomain`, all of `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, and the IPv4-mapped forms `URL` normalizes to hex (`::ffff:7f00:1`); a terminal DNS root dot is stripped first | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. |
|
||||
| Otherwise | static `cmd` | Solution prefix stripped by `normalizeStalkerPlaybackCommand()`. |
|
||||
|
||||
`fetchStalkerPlaybackLink()` applies the verdict for ITV, VOD and radio, and
|
||||
short-circuits before the request. It never applies it when `series` is set:
|
||||
an episode is selected server-side by that parameter, so the parent row's
|
||||
static `cmd` addresses the series, not the episode.
|
||||
|
||||
**The flags must survive normalization.** `buildStalkerSelectedVodItem()`
|
||||
(`stalker-vod.utils.ts`) narrows a raw portal row to a whitelist, so a field it
|
||||
does not name is silently dropped — and it feeds both VOD playback
|
||||
(`selectedItem()`) and the download payload (`createStalkerVodItem` passes its
|
||||
`data` straight through). Losing a flag there fails **open**: the row reads as
|
||||
"no temporary link needed" and the static path plays the portal's non-final
|
||||
URL. Both flags are therefore on the whitelist, on `StalkerVodSource` /
|
||||
`StalkerSelectedVodItem`, and pinned by tests in `stalker-vod.utils.spec.ts`.
|
||||
Any new normalizer between a portal response and a playback call has the same
|
||||
obligation.
|
||||
|
||||
Callers pass the row they resolved the `cmd` from:
|
||||
|
||||
- ITV and radio — the channel/station row (`withStalkerPlayer`); radio
|
||||
previously bypassed `create_link` for any directly playable command, which
|
||||
meant a proxied station played a URL the portal never intended to serve.
|
||||
- VOD and series — `selectedItem()`.
|
||||
- Downloads — the movie payload, through the optional `linkFlags` argument on
|
||||
`fetchLinkToPlay()`, but only for a credential-free URL (see below).
|
||||
- Favorites and Recently Viewed — `StreamResolverService.resolveStalker()`
|
||||
reads the flags off the persisted raw row (`UnifiedCollectionItem.stalkerItem`).
|
||||
Radio keeps its long-standing "directly usable command plays as-is"
|
||||
behaviour, keyed off flag EVIDENCE rather than snapshot presence — a radio
|
||||
row persisted before the flags were carried has a snapshot that simply lacks
|
||||
them, and testing presence would skip the fallback for exactly those rows.
|
||||
`withStalkerPlayer`'s radio branch applies the identical rule; the two must
|
||||
not drift.
|
||||
|
||||
### The static path still needs the session
|
||||
|
||||
`create_link` was also the request that warmed the portal session, and tokens
|
||||
live in memory only (`StalkerSessionService.tokenCache`). Skipping it therefore
|
||||
has to account for streams that are still gated on the Bearer token — and
|
||||
"which routes are already warm" is not a question worth answering per route:
|
||||
the global collection detail sets the playlist and the selected item straight
|
||||
from a persisted row, with no catalog load in between, so a VOD opened from
|
||||
Favorites reaches the store's playback path stone cold.
|
||||
|
||||
Every static return therefore warms first, through one primitive —
|
||||
`ensureStalkerSession()` in `stalker-request.utils.ts`, wrapping
|
||||
`StalkerSessionService.ensureToken()`:
|
||||
|
||||
- `fetchStalkerPlaybackLink()` calls it before returning a static URL, which
|
||||
covers ITV, VOD, radio and downloads at a single choke point.
|
||||
- `StreamResolverService` calls it on its own static branch, which does not go
|
||||
through that function.
|
||||
|
||||
`ensureToken` performs handshake + `get_profile` with no link minted, and
|
||||
validates the identity the cached token was negotiated for — which the raw
|
||||
`getCachedToken()` cannot. It is cheap where it is not needed: a simple portal
|
||||
returns immediately and a warm cache with a matching fingerprint resolves
|
||||
without a request.
|
||||
|
||||
The store's player feature reads `getCachedToken()` for its header set, which
|
||||
is safe there because it runs immediately after the warm above populated the
|
||||
cache for that same playlist. `StreamResolverService` cannot make that
|
||||
assumption — a direct-URL favorite reaches it with nothing warmed — so it goes
|
||||
through `ensureToken` instead; see "Playback Header Contract" below.
|
||||
|
||||
The classification happens BEFORE the handshake, not after: a **foreign-host**
|
||||
static URL never needs the session at all, and warming it anyway would stall
|
||||
playback behind a request worth up to 15 s against a portal that may be slow
|
||||
or offline while the CDN is perfectly reachable — for a result that is then
|
||||
discarded.
|
||||
|
||||
A **foreign-host** static URL is returned before the handshake is even
|
||||
attempted — it never needed the session. A **portal-owned** one with no usable
|
||||
session (handshake failed, or threw) would be served knowing it will 401, so
|
||||
both call sites fall back to `create_link` instead — which mints a URL
|
||||
carrying its own token and, crucially, is the only path that can observe a
|
||||
failure and trigger the lazy portal repair. That keeps a playlist still
|
||||
misclassified as token-free, or pointing at an unrepaired endpoint, on the
|
||||
self-healing path it was on before this change.
|
||||
|
||||
`ensureStalkerSession` returns that verdict: `true` for a portal that needs no
|
||||
token and for one holding a usable token, `false` for a full portal left
|
||||
without one. It swallows a throwing handshake rather than propagating it — an
|
||||
unreachable portal must not surface as an exception mid-playback — which
|
||||
simply makes the verdict `false` and routes the row to `create_link`.
|
||||
|
||||
**Known trade-off: a cached token is not revalidated.** `ensureToken` returns a
|
||||
same-identity cache entry without touching the network, so the static path no
|
||||
longer self-heals a token the server has retired — something `create_link`
|
||||
used to do for free, since `makeAuthenticatedRequest` retires and re-auths on
|
||||
an authorization failure. This is narrower than it sounds: per the 4.9.35
|
||||
reference, handshake tokens have **no TTL**, and failing to send the watchdog
|
||||
does **not** invalidate auth (it only clears the admin panel's "online"
|
||||
status). The one real vector left is another device performing `get_profile`
|
||||
on the same MAC — common enough on shared subscriptions, but wherever a
|
||||
watchdog is running it still self-heals within a ping cycle, because the ping
|
||||
goes through `makeAuthenticatedRequest` too. What is left uncovered is a
|
||||
same-host static stream played while no watchdog is up.
|
||||
|
||||
Revalidating on every static playback would cost exactly the round trip this
|
||||
section exists to remove, so it is deliberately not done here. The right home
|
||||
for a fix is the auth lifecycle (PR 6): refresh on an observed playback
|
||||
authorization failure, rather than pre-emptively on every play.
|
||||
|
||||
**Downloads are the exception, and cannot use this.** A download request cannot
|
||||
carry portal credentials at all — the main-process stored-header allowlist is
|
||||
`User-Agent` / `Origin` / `Referer` only
|
||||
(`download-request-headers.ts`), with no `Cookie` or `Authorization`. So
|
||||
`startStalkerVodDownload` offers the static shortcut only for a URL that needs
|
||||
none: it classifies the candidate with `isStalkerStreamCredentialSafe()` and
|
||||
withholds the row (forcing `create_link`) for anything portal-owned. A
|
||||
same-host movie keeps using the minted URL, which carries its own access token;
|
||||
a CDN-hosted one keeps the permanent URL that survives retry.
|
||||
|
||||
### Resolved links are never stored
|
||||
|
||||
A temporary link lives about 5 seconds (`tv_tmp_link_ttl` /
|
||||
`vclub_tmp_link_ttl`, both default 5). It is time-limited but not single-use,
|
||||
so the rule is to resolve immediately before playback and never persist,
|
||||
cache or replay the result. What each persisting path actually stores:
|
||||
|
||||
| Path | Stores | Verdict |
|
||||
| --- | --- | --- |
|
||||
| Recently Viewed | the raw row including `cmd` (`buildStalkerRecentlyViewedPayload` spreads the item) | Re-resolves on replay. |
|
||||
| Favorites | the raw row including `cmd` (`addStalkerFavorite`, `toggleFavorite`) | Re-resolves on replay. |
|
||||
| Playback positions | `playlist_id` + `content_xtream_id` + content type only — no URL column | Not applicable. |
|
||||
| Main-process playback context (`stalker-playback-context.service.ts`) | header sets keyed by the stream URL's origin + path, 15 min TTL | Stores no URL. The key drops the query, so a re-minted link with a fresh token still finds its headers instead of playing bare. |
|
||||
| ITV full-list cache (`StalkerItvCacheService`) | catalog rows | Rows, not links. |
|
||||
| Downloads | the resolved `url` on the `downloads` row | **The one exception** — see below. |
|
||||
|
||||
The download row is the only place a resolved URL outlives the playback that
|
||||
produced it, because the main-process downloader needs a URL it can retry and
|
||||
resume with. Honouring the flags shrinks the exposure: an unflagged movie on a
|
||||
host that needs no portal credentials now yields a permanent URL that survives
|
||||
retry. A movie that needs a temporary link — or one on the portal host, which a
|
||||
download cannot authenticate against — still stores a link that is dead by the
|
||||
time retry runs. Fixing that needs the `cmd` on the download row plus a
|
||||
re-resolution step before retry/resume, which is a schema change and is
|
||||
deliberately out of scope here.
|
||||
|
||||
### `forced_storage` and `play_token`
|
||||
|
||||
Both are deliberately unused, and neither appears in the 4.9.35 reference
|
||||
fact set as a parameter the stock server enforces:
|
||||
|
||||
- **`forced_storage`** is a `create_link` request parameter that pins VOD
|
||||
playback to one storage server. It exists for clients that let the user pick
|
||||
a storage; IPTVnator has no such concept, and omitting the parameter is what
|
||||
produces the empty value the portal treats as "no preference". Wiring it
|
||||
would first need storage discovery and a picker in the VOD detail view.
|
||||
- **`play_token`** is a `create_link` response field for clients that assemble
|
||||
the stream URL themselves. IPTVnator plays the `cmd` the portal returns
|
||||
verbatim (after the solution-prefix strip and base resolution above), so the
|
||||
token the stream needs is already in the URL. Note the coupling with the
|
||||
section above: on the static path no `create_link` runs at all, so no
|
||||
`play_token` is ever produced — which is consistent, because a row that
|
||||
wants neither flag is announcing that its `cmd` needs no portal-minted
|
||||
credential.
|
||||
|
||||
Revisit both only with a portal that demonstrably fails without them.
|
||||
|
||||
### Regression coverage
|
||||
|
||||
**Writing tests against this section:** the decision chain has several exits —
|
||||
no flag evidence, unresolvable command shape, `series` set, foreign host,
|
||||
session unusable — and more than one of them can satisfy the same assertion.
|
||||
Four tests in the PR that introduced this were found passing through an exit
|
||||
other than the one they named (a foreign-host command reaches neither the
|
||||
handshake nor `create_link`, so it silently stands in for "simple portal" or
|
||||
"handshake failed"). Mutation testing does not catch it: it proves a test is
|
||||
coupled to its target, not that it reached the mechanism in its name. Check the
|
||||
mock setup against the execution path, and assert the step you mean was
|
||||
actually taken — `expect(ensureToken).toHaveBeenCalled()` rather than only the
|
||||
returned URL.
|
||||
|
||||
- `stalker-link-semantics.utils.spec.ts` — the decision table above.
|
||||
- `stalker-vod.utils.spec.ts` — both flags survive
|
||||
`buildStalkerSelectedVodItem` / `normalizeStalkerVodDetailsItem` /
|
||||
`normalizeStalkerFavoriteItem`, and an unflagged row gains no flags.
|
||||
- `stalker-player-request.utils.spec.ts` — static short-circuit, both flags,
|
||||
the `series` exception, relative VOD commands, the session warm-up (simple
|
||||
portal skipped, repaired endpoint used, failure degraded) and the
|
||||
portal-owned-without-session fallback to `create_link`.
|
||||
- `with-stalker-player.feature.spec.ts` — ITV/radio store paths and proof that
|
||||
Recently Viewed stores the `cmd`, never the stream URL.
|
||||
- `stream-resolver.service.spec.ts` — the collection route, plus the cold
|
||||
full-portal session warm-up and its best-effort degradation.
|
||||
- `stalker-vod-download.spec.ts` — a CDN movie takes the static shortcut, a
|
||||
same-host one keeps minting.
|
||||
- `stalker-playback-context.service.spec.ts` — headers only, query-insensitive
|
||||
key.
|
||||
- `apps/web-e2e/src/stalker.e2e.ts` — mock scenario `00:1A:79:00:00:0A` serves
|
||||
unflagged ITV rows with a playable `cmd`; the spec asserts NO `create_link`
|
||||
request reaches the portal, and a companion test on the default (flagged)
|
||||
scenario proves the recorder does see one when a link is due.
|
||||
|
||||
## Playback Header Contract
|
||||
|
||||
Every playback kind — ITV, VOD, series episodes, and radio — resolves its
|
||||
@@ -326,7 +727,10 @@ live layout for the radio audio player, which renders outside
|
||||
Two stream profiles exist, selected by one shared predicate:
|
||||
|
||||
- **Portal-owned** (`isStalkerStreamCredentialSafe()` in
|
||||
`@iptvnator/shared/interfaces`): the stream host equals the portal host —
|
||||
`@iptvnator/shared/interfaces`): the stream host equals the portal host
|
||||
(compared with a terminal DNS root dot normalized away, since
|
||||
`portal.example.` and `portal.example` are the same host but `URL` keeps
|
||||
the dot) —
|
||||
including a different port or an http→https upgrade, the routine IPTV panel
|
||||
shape (#1158 class). These streams get the full MAG profile: `Cookie`
|
||||
(`mac=…` plus protocol cookies), `Authorization: Bearer <token>` when a
|
||||
@@ -340,6 +744,30 @@ Two stream profiles exist, selected by one shared predicate:
|
||||
never reach a third-party host; direct stream URLs carry their access token
|
||||
in the URL minted by `create_link`.
|
||||
|
||||
**The token is bound to the endpoint the headers claim.** Both header inputs —
|
||||
the portal coordinates and the Bearer token — must describe the same portal, or
|
||||
a session negotiated for one host is presented to another. In
|
||||
`StreamResolverService` that is structural: the token is resolved from the same
|
||||
`headerPlaylist` object the headers are built from, never from the row it was
|
||||
derived from. The live case is a completed lazy repair, which moves the
|
||||
endpoint in the override but not in the stored row. Resolving from the override
|
||||
also keys the session cache the way `ensureStalkerSession()` and
|
||||
`executeStalkerRequest()` already do, so the collection route reuses their
|
||||
session instead of handshaking again for a second fingerprint.
|
||||
|
||||
For the same reason `headerPlaylist` is built by applying the override to the
|
||||
row rather than folding in the two resolved coordinates: a repair rewrites the
|
||||
portal **mode** as well as the URL, and the token resolver is mode-aware. A
|
||||
playlist repaired from simple to full would otherwise keep its stale
|
||||
`isFullStalkerPortal: false` here — the `create_link` request having already
|
||||
run under the repaired mode and adopted a token — and the same-host gated
|
||||
stream would go out with no Bearer header on the very playback the repair
|
||||
existed to rescue.
|
||||
|
||||
That resolution is skipped for a foreign host, whose profile carries no token
|
||||
anyway — obtaining one would only stall playback behind a handshake, exactly
|
||||
the trade the static branch avoids by classifying first.
|
||||
|
||||
The Electron main process keeps a fallback header context per resolved
|
||||
`create_link` URL (`stalker-playback-context.service.ts`) for external-player
|
||||
launches that arrive without renderer headers. It classifies streams with the
|
||||
|
||||
+14
-3
@@ -101,16 +101,27 @@
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
<!-- <mat-form-field class="w-full">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label for="username">{{
|
||||
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput type="text" id="username" formControlName="username" />
|
||||
<mat-hint>{{
|
||||
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label for="password">{{
|
||||
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
<input matInput type="text" id="password" formControlName="password" />
|
||||
</mat-form-field> -->
|
||||
<input
|
||||
matInput
|
||||
type="password"
|
||||
id="password"
|
||||
formControlName="password"
|
||||
/>
|
||||
<mat-hint>{{
|
||||
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
+179
-4
@@ -2,13 +2,19 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
stalkerSessionFingerprint,
|
||||
StalkerPortalDiscoveryService,
|
||||
StalkerPortalError,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalImportComponent } from './stalker-portal-import.component';
|
||||
|
||||
describe('StalkerPortalImportComponent identity handling', () => {
|
||||
let component: StalkerPortalImportComponent;
|
||||
let portalDiscovery: { discover: jest.Mock };
|
||||
let store: { dispatch: jest.Mock };
|
||||
let snackBar: { open: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
portalDiscovery = {
|
||||
@@ -23,6 +29,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
snackBar = { open: jest.fn() };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -33,7 +40,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
{ provide: Store, useValue: store },
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: { open: jest.fn() },
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
@@ -72,7 +79,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
deviceId2: 'DEVICE-ID-2',
|
||||
signature1: 'SIGNATURE-1',
|
||||
signature2: 'SIGNATURE-2',
|
||||
}
|
||||
},
|
||||
// Credentials ride along for portals that answer get_profile
|
||||
// with status 2 (login/password required).
|
||||
{ credentials: { username: '', password: '' } }
|
||||
);
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
@@ -115,7 +125,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
expect(portalDiscovery.discover).toHaveBeenCalledWith(
|
||||
'https://portal.example.com/stalker_portal/c',
|
||||
'00:1A:79:AA:BB:CC',
|
||||
{}
|
||||
{},
|
||||
{ credentials: { username: '', password: '' } }
|
||||
);
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
@@ -131,6 +142,170 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
expect(playlist.signature2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes the entered login and password into discovery', async () => {
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-login',
|
||||
title: 'Login Portal',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(portalDiscovery.discover).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'00:1A:79:00:00:08',
|
||||
{},
|
||||
{ credentials: { username: 'user', password: 'secret' } }
|
||||
);
|
||||
// Persisted so runtime re-auth can repeat do_auth after the portal
|
||||
// drops the session.
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.username).toBe('user');
|
||||
expect(playlist.password).toBe('secret');
|
||||
});
|
||||
|
||||
it('persists the cadence and the identity the token was negotiated for', async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
watchdogTimeoutSeconds: 90,
|
||||
timeslotSeconds: 11,
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-cadence',
|
||||
title: 'Cadence Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.stalkerWatchdogTimeout).toBe(90);
|
||||
expect(playlist.stalkerTimeslot).toBe(11);
|
||||
// Without this a later start would re-present the token under an
|
||||
// edited identity.
|
||||
expect(playlist.stalkerSessionIdentity).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('records credentials in the imported session fingerprint', async () => {
|
||||
// Otherwise the first runtime ensureToken() computes a fingerprint
|
||||
// WITH the credentials, mismatches the imported one, and discards the
|
||||
// session the import just established — silently defeating reuse for
|
||||
// exactly the login portals this flow exists for.
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-login-fp',
|
||||
title: 'Login Portal',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.stalkerSessionIdentity).toBe(
|
||||
stalkerSessionFingerprint({
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
} as Playlist)
|
||||
);
|
||||
});
|
||||
|
||||
it('records the effective cadence when the portal advertises none', async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-default-cadence',
|
||||
title: 'Quiet Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
// Stored absence has to keep meaning "never profiled", or every
|
||||
// later start would re-profile such a portal.
|
||||
expect(playlist.stalkerWatchdogTimeout).toBe(120);
|
||||
expect(playlist.stalkerTimeslot).toBe(0);
|
||||
});
|
||||
|
||||
it("relays the portal's own refusal instead of a generic error", async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'auth-rejected',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
error: new StalkerPortalError('login-required'),
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-refused',
|
||||
title: 'Login Portal',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends the portal's explanation to a blocked refusal", async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'auth-rejected',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
error: new StalkerPortalError(
|
||||
'blocked',
|
||||
'device conflict - device_id mismatch'
|
||||
),
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-blocked',
|
||||
title: 'Blocked Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
// The translate mock returns keys, so both the kind headline and the
|
||||
// portal-message wrapper must be present.
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.PORTAL_REFUSED HOME.STALKER_PORTAL.PORTAL_MESSAGE',
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies the offline fallback on the normalized URL, not the raw query', async () => {
|
||||
// A query merely MENTIONING /server/load.php must not make a
|
||||
// panel-style /c URL look canonical and abort the offline import.
|
||||
|
||||
+78
-3
@@ -13,11 +13,15 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
asStalkerPortalError,
|
||||
legacyTransformStalkerPortalUrl,
|
||||
normalizeStalkerPortalInputUrl,
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
|
||||
StalkerPortalDiscoveryService,
|
||||
StalkerPortalIdentity,
|
||||
normalizeStalkerPortalIdentity,
|
||||
stalkerSessionFingerprint,
|
||||
type StalkerPortalErrorKind,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
createRandomId,
|
||||
@@ -129,18 +133,37 @@ export class StalkerPortalImportComponent {
|
||||
const discovery = await this.portalDiscovery.discover(
|
||||
originalUrl,
|
||||
formValue.macAddress ?? '',
|
||||
stalkerIdentity
|
||||
stalkerIdentity,
|
||||
{
|
||||
credentials: {
|
||||
username: formValue.username ?? '',
|
||||
password: formValue.password ?? '',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let portalUrl: string;
|
||||
let isFullStalkerPortal: boolean;
|
||||
let stalkerToken: string | undefined;
|
||||
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
|
||||
// The import profile is the only get_profile some portals ever
|
||||
// see: later starts reuse the token and skip it, so the cadence
|
||||
// it advertises has to be persisted here or the watchdog would
|
||||
// stay on the 120 s default forever. Effective values, so stored
|
||||
// absence keeps meaning "never profiled".
|
||||
let stalkerWatchdogTimeout: number | undefined;
|
||||
let stalkerTimeslot: number | undefined;
|
||||
|
||||
if (discovery.status === 'resolved') {
|
||||
portalUrl = discovery.portalUrl;
|
||||
isFullStalkerPortal = discovery.isFullStalkerPortal;
|
||||
stalkerToken = discovery.token;
|
||||
if (stalkerToken) {
|
||||
stalkerWatchdogTimeout =
|
||||
discovery.watchdogTimeoutSeconds ??
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS;
|
||||
stalkerTimeslot = discovery.timeslotSeconds ?? 0;
|
||||
}
|
||||
|
||||
if (discovery.accountInfo) {
|
||||
stalkerAccountInfo = {
|
||||
@@ -167,10 +190,13 @@ export class StalkerPortalImportComponent {
|
||||
'[StalkerImport] Authentication failed:',
|
||||
discovery.error
|
||||
);
|
||||
// The portal explains its own refusals — a demanded login, a
|
||||
// rejected one, a device conflict — so relay those words
|
||||
// instead of the generic "check URL and MAC".
|
||||
this.snackBar.open(
|
||||
'Failed to authenticate with portal. Please check URL and MAC address.',
|
||||
this.buildAuthErrorMessage(discovery.error),
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
{ duration: 8000 }
|
||||
);
|
||||
return;
|
||||
} else if (
|
||||
@@ -219,6 +245,25 @@ export class StalkerPortalImportComponent {
|
||||
portalUrl,
|
||||
isFullStalkerPortal,
|
||||
stalkerToken,
|
||||
// What this token was negotiated for: endpoint, identity AND
|
||||
// credentials. Reuse is refused when any of them no longer
|
||||
// matches — and the credentials must be included here, or the
|
||||
// first runtime `ensureToken()` would compute a fingerprint
|
||||
// WITH them, mismatch this one, and throw away the session
|
||||
// the import just established.
|
||||
...(stalkerToken
|
||||
? {
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint({
|
||||
portalUrl,
|
||||
macAddress: formValue.macAddress ?? '',
|
||||
username: formValue.username ?? '',
|
||||
password: formValue.password ?? '',
|
||||
...this.toPlaylistIdentityFields(stalkerIdentity),
|
||||
} as Playlist),
|
||||
}
|
||||
: {}),
|
||||
stalkerWatchdogTimeout,
|
||||
stalkerTimeslot,
|
||||
stalkerAccountInfo,
|
||||
...this.toPlaylistIdentityFields(stalkerIdentity),
|
||||
} as Playlist;
|
||||
@@ -230,6 +275,36 @@ export class StalkerPortalImportComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an authentication failure into a message the user can act on.
|
||||
* The portal explains refusals itself (`msg`/`block_msg`, or one of the
|
||||
* documented plain-text bodies); its own words are appended verbatim.
|
||||
*/
|
||||
private buildAuthErrorMessage(error: unknown): string {
|
||||
const portalError = asStalkerPortalError(error);
|
||||
const keyByKind: Record<StalkerPortalErrorKind, string> = {
|
||||
'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
|
||||
'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED',
|
||||
blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
|
||||
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
|
||||
};
|
||||
const base = this.translate.instant(
|
||||
portalError
|
||||
? keyByKind[portalError.kind]
|
||||
: 'HOME.STALKER_PORTAL.AUTH_FAILED'
|
||||
);
|
||||
|
||||
if (portalError?.portalText) {
|
||||
const detail = this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.PORTAL_MESSAGE',
|
||||
{ message: portalError.portalText }
|
||||
);
|
||||
return `${base} ${detail}`;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
private toPlaylistIdentityFields(identity: StalkerPortalIdentity): {
|
||||
stalkerSerialNumber?: string;
|
||||
stalkerDeviceId1?: string;
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
XtreamApiService,
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
StalkerPortalRepairService,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
DataService,
|
||||
@@ -25,6 +28,7 @@ describe('StreamResolverService', () => {
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
let stalkerSession: {
|
||||
getCachedToken: jest.Mock;
|
||||
ensureToken: jest.Mock;
|
||||
makeAuthenticatedRequest: jest.Mock;
|
||||
};
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
@@ -44,6 +48,7 @@ describe('StreamResolverService', () => {
|
||||
};
|
||||
stalkerSession = {
|
||||
getCachedToken: jest.fn(() => null),
|
||||
ensureToken: jest.fn().mockResolvedValue({ token: null }),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
};
|
||||
epgBridge = {
|
||||
@@ -703,6 +708,48 @@ describe('StreamResolverService', () => {
|
||||
expect(detail.playback.headers?.['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('plays a legacy radio favorite whose snapshot lost the flags', async () => {
|
||||
// The row above this one carries NO `stalkerItem`, so it exercises the
|
||||
// missing-snapshot arm. A radio favorite persisted before the flags
|
||||
// were carried has a snapshot that simply lacks them — testing
|
||||
// presence instead of evidence would skip the radio fallback and start
|
||||
// minting for exactly those rows, breaking portals whose radio
|
||||
// `create_link` is unsupported.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
const detail = await service.resolveLiveDetail({
|
||||
uid: 'stalker::stalker-1::40003',
|
||||
name: 'Legacy Radio',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '40003',
|
||||
stalkerCmd: 'ffmpeg https://media.example.com/legacy.mp3',
|
||||
logo: 'legacy.png',
|
||||
radio: 'true',
|
||||
// Present, but stripped of the flags by the old whitelist.
|
||||
stalkerItem: {
|
||||
id: '40003',
|
||||
cmd: 'ffmpeg https://media.example.com/legacy.mp3',
|
||||
name: 'Legacy Radio',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
|
||||
expect(detail.playback.streamUrl).toBe(
|
||||
'https://media.example.com/legacy.mp3'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves relative Stalker create_link responses against the portal base', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
@@ -749,7 +796,10 @@ describe('StreamResolverService', () => {
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue('TOKEN77');
|
||||
// Playback goes through ensureToken, never the raw cache accessor:
|
||||
// that one skips the endpoint/identity/credential check, so an
|
||||
// edited playlist would put the old account's token in the headers.
|
||||
stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN77' });
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg https://stalker.example.com:8080/live/88.ts' },
|
||||
});
|
||||
@@ -765,7 +815,7 @@ describe('StreamResolverService', () => {
|
||||
stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.getCachedToken).toHaveBeenCalledWith('stalker-1');
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalled();
|
||||
expect(playback.headers?.['Cookie']).toContain(
|
||||
'mac=00:11:22:33:44:55'
|
||||
);
|
||||
@@ -775,6 +825,202 @@ describe('StreamResolverService', () => {
|
||||
expect(playback.origin).toBe('https://stalker.example.com');
|
||||
});
|
||||
|
||||
it('binds the playback token to the endpoint the headers claim', async () => {
|
||||
// A completed repair moved the endpoint. The headers are built from
|
||||
// that moved endpoint, so the session must be negotiated for it too —
|
||||
// authenticating against the pre-repair row would send a token minted
|
||||
// for one host to another (and key the session cache differently from
|
||||
// every other consumer, forcing a redundant handshake).
|
||||
const stored = {
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://old.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>;
|
||||
const repaired =
|
||||
'https://new.example.com/stalker_portal/server/load.php';
|
||||
playlistsService.getPlaylistById.mockReturnValue(of(stored));
|
||||
jest.spyOn(
|
||||
TestBed.inject(StalkerPortalRepairService),
|
||||
'applyOverride'
|
||||
).mockImplementation(
|
||||
(playlist: any) => ({ ...playlist, portalUrl: repaired }) as any
|
||||
);
|
||||
stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN88' });
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg https://new.example.com:8080/live/88.ts' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::88',
|
||||
name: 'Moved Portal Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '88',
|
||||
stalkerCmd: 'ffrt3 http://old.example.com/media/88.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ portalUrl: repaired })
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN88');
|
||||
expect(playback.origin).toBe('https://new.example.com');
|
||||
});
|
||||
|
||||
it('carries a repaired simple→full mode into the playback headers', async () => {
|
||||
// A lazy repair rewrites the portal MODE as well as the URL. The
|
||||
// create_link request runs under the repaired mode and adopts a token,
|
||||
// so reading the stored row's stale `isFullStalkerPortal: false` when
|
||||
// resolving that token would drop the Bearer header from the very
|
||||
// playback the repair was meant to rescue.
|
||||
const stored = {
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://portal.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>;
|
||||
playlistsService.getPlaylistById.mockReturnValue(of(stored));
|
||||
jest.spyOn(
|
||||
TestBed.inject(StalkerPortalRepairService),
|
||||
'applyOverride'
|
||||
).mockImplementation(
|
||||
(playlist: any) =>
|
||||
({
|
||||
...playlist,
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
}) as any
|
||||
);
|
||||
stalkerSession.ensureToken.mockResolvedValue({ token: 'REPAIRED77' });
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg https://portal.example.com:8080/live/95.ts' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::95',
|
||||
name: 'Repaired Portal Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '95',
|
||||
stalkerCmd: 'ffrt3 http://portal.example.com/media/95.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isFullStalkerPortal: true })
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer REPAIRED77');
|
||||
});
|
||||
|
||||
it('authenticates a cold session for a direct-URL radio favorite', async () => {
|
||||
// A direct-URL radio favorite skips create_link entirely, so on a
|
||||
// cold session nothing has authenticated and the in-memory cache is
|
||||
// empty — the Bearer-gated stream would 403 in the audio player.
|
||||
// Re-presenting the persisted token costs one idempotent handshake.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: true,
|
||||
stalkerToken: 'PERSISTED77',
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue(null);
|
||||
stalkerSession.ensureToken.mockResolvedValue({
|
||||
token: 'PERSISTED77',
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::99',
|
||||
name: 'Gated Radio',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '99',
|
||||
radio: 'true',
|
||||
stalkerCmd: 'https://stalker.example.com/radio/99.mp3',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalled();
|
||||
// The fast path must stay a fast path: no create_link round trip.
|
||||
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer PERSISTED77');
|
||||
});
|
||||
|
||||
it('falls back to create_link when the cold session cannot be established', async () => {
|
||||
// A portal-owned static stream with no usable session would be served
|
||||
// knowing it will 401, so playback is not blocked — it takes the
|
||||
// `create_link` route instead, which mints a URL carrying its own
|
||||
// token and is the only path that can trigger the lazy portal repair.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue(null);
|
||||
stalkerSession.ensureToken.mockRejectedValue(
|
||||
new Error('handshake refused')
|
||||
);
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg https://stalker.example.com/radio/99-tmp.mp3' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::99',
|
||||
name: 'Gated Radio',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '99',
|
||||
radio: 'true',
|
||||
stalkerCmd: 'https://stalker.example.com/radio/99.mp3',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe(
|
||||
'https://stalker.example.com/radio/99-tmp.mp3'
|
||||
);
|
||||
// No session, so no Bearer — the minted URL carries its own token.
|
||||
expect(playback.headers?.['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not authenticate a simple portal for playback headers', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-2',
|
||||
portalUrl: 'https://simple.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue(null);
|
||||
|
||||
await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-2::99',
|
||||
name: 'Simple Radio',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-2',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '99',
|
||||
radio: 'true',
|
||||
stalkerCmd: 'https://simple.example.com/radio/99.mp3',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Stalker collection playback from a foreign CDN credential-free', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
@@ -808,6 +1054,257 @@ describe('StreamResolverService', () => {
|
||||
expect(playback.origin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('plays an unflagged Stalker favorite from its stored cmd without create_link', async () => {
|
||||
// Favorites persist the raw catalog row, so `use_http_tmp_link` /
|
||||
// `use_load_balancing` come back with it — a row that sets neither is
|
||||
// playable as it stands and must not cost a portal round trip.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::90',
|
||||
name: 'Static Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '90',
|
||||
stalkerCmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
|
||||
stalkerItem: {
|
||||
id: '90',
|
||||
cmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
// The detail route still loads EPG; what must not happen is a link
|
||||
// request.
|
||||
const requestedActions = [
|
||||
...dataService.sendIpcEvent.mock.calls,
|
||||
...stalkerSession.makeAuthenticatedRequest.mock.calls,
|
||||
].map(
|
||||
(call) =>
|
||||
(call[1] as { params?: { action?: string } } | undefined)
|
||||
?.params?.action
|
||||
);
|
||||
expect(requestedActions).not.toContain('create_link');
|
||||
expect(playback.streamUrl).toBe('http://cdn.example.com/live/90.m3u8');
|
||||
expect(playback.isLive).toBe(true);
|
||||
});
|
||||
|
||||
it('authenticates a cold full-portal session before a static stream', async () => {
|
||||
// Skipping create_link also skips the request that used to warm the
|
||||
// session. Tokens are in-memory only, so a cold start from global
|
||||
// Favorites would otherwise hand a same-host gated stream headers
|
||||
// with no Authorization and take a 403.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
// Cold: no token until ensureToken has run.
|
||||
stalkerSession.getCachedToken.mockReturnValue(null);
|
||||
stalkerSession.ensureToken.mockImplementation(async () => {
|
||||
stalkerSession.getCachedToken.mockReturnValue('TOKEN-COLD');
|
||||
return { token: 'TOKEN-COLD' };
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::91',
|
||||
name: 'Cold Static Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '91',
|
||||
stalkerCmd: 'ffrt3 https://stalker.example.com/live/91.m3u8',
|
||||
stalkerItem: {
|
||||
id: '91',
|
||||
cmd: 'ffrt3 https://stalker.example.com/live/91.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: 'stalker-1' })
|
||||
);
|
||||
expect(playback.streamUrl).toBe(
|
||||
'https://stalker.example.com/live/91.m3u8'
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN-COLD');
|
||||
});
|
||||
|
||||
it('serves a foreign-host static stream without waiting on the portal', async () => {
|
||||
// The CDN needs no portal credentials, so the handshake would be a
|
||||
// discarded result — and a stall of up to 15 s when the portal is
|
||||
// slow or offline but the CDN is fine.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::94',
|
||||
name: 'Cdn Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '94',
|
||||
stalkerCmd: 'ffrt3 https://cdn.example.com/live/94.m3u8',
|
||||
stalkerItem: {
|
||||
id: '94',
|
||||
cmd: 'ffrt3 https://cdn.example.com/live/94.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe('https://cdn.example.com/live/94.m3u8');
|
||||
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to create_link when the handshake throws', async () => {
|
||||
// A throwing handshake must not propagate. It leaves the session
|
||||
// unusable, so a PORTAL-OWNED url goes to `create_link` rather than
|
||||
// being served as a known 401 — the url has to be portal-owned for
|
||||
// the handshake to run at all now that classification comes first.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.ensureToken.mockRejectedValue(new Error('portal down'));
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'https://stalker.example.com/tmp/92.ts?tok=1' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::92',
|
||||
name: 'Portal Static Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '92',
|
||||
stalkerCmd: 'ffrt3 https://stalker.example.com/live/92.m3u8',
|
||||
stalkerItem: {
|
||||
id: '92',
|
||||
cmd: 'ffrt3 https://stalker.example.com/live/92.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalled();
|
||||
expect(playback.streamUrl).toBe(
|
||||
'https://stalker.example.com/tmp/92.ts?tok=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the edited playlist coordinates over a stale favorite snapshot', async () => {
|
||||
// A favorite persists the portal URL and MAC it was saved with. After
|
||||
// the playlist is edited, the session token is negotiated for the NEW
|
||||
// identity — sending it with the old MAC cookie (or to the old host)
|
||||
// is the mismatch the identity fingerprint exists to prevent.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://new.example.com/portal.php',
|
||||
macAddress: 'AA:BB:CC:00:00:99',
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
// A full portal, so the Bearer assertion below is meaningful: only a
|
||||
// portal with a session has a token to attach at all.
|
||||
stalkerSession.ensureToken.mockResolvedValue({ token: 'TOKEN-NEW' });
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::93',
|
||||
name: 'Edited Portal Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '93',
|
||||
stalkerCmd: 'ffrt3 https://new.example.com/live/93.m3u8',
|
||||
// Stale snapshot from before the edit.
|
||||
stalkerPortalUrl: 'https://old.example.com/portal.php',
|
||||
stalkerMacAddress: '00:11:22:33:44:55',
|
||||
stalkerItem: {
|
||||
id: '93',
|
||||
cmd: 'ffrt3 https://new.example.com/live/93.m3u8',
|
||||
use_http_tmp_link: '0',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(playback.headers?.['Cookie']).toContain('mac=AA:BB:CC:00:00:99');
|
||||
expect(playback.headers?.['Cookie']).not.toContain(
|
||||
'mac=00:11:22:33:44:55'
|
||||
);
|
||||
// Same host as the edited row ⇒ portal-owned ⇒ credentials attached.
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN-NEW');
|
||||
expect(playback.origin).toBe('https://new.example.com');
|
||||
});
|
||||
|
||||
it('mints a link for a flagged Stalker favorite', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg http://cdn.example.com/tmp/90.m3u8?tok=1' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::90',
|
||||
name: 'Balanced Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '90',
|
||||
stalkerCmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
|
||||
stalkerItem: {
|
||||
id: '90',
|
||||
cmd: 'ffrt3 http://cdn.example.com/live/90.m3u8',
|
||||
use_load_balancing: '1',
|
||||
},
|
||||
} as UnifiedCollectionItem);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ action: 'create_link' }),
|
||||
})
|
||||
);
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example.com/tmp/90.m3u8?tok=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends query-only Stalker create_link responses to the original cmd URL', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
Channel,
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
isFullStalkerPortalPlaylist,
|
||||
Playlist,
|
||||
isStalkerStreamCredentialSafe,
|
||||
ResolvedPortalPlayback,
|
||||
STALKER_REQUEST,
|
||||
StalkerPortalActions,
|
||||
@@ -19,15 +21,21 @@ import {
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
buildStalkerExternalPlaybackHeaders,
|
||||
ensureStalkerSession,
|
||||
executeStalkerRequest,
|
||||
getStalkerPortalOrigin,
|
||||
hasStalkerLinkFlagEvidence,
|
||||
isCrossOriginStalkerStream,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
resolveStalkerPlaybackUrl,
|
||||
resolveStalkerStaticPlaybackUrl,
|
||||
StalkerPortalRepairService,
|
||||
StalkerSessionService,
|
||||
type StalkerLinkFlagSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
UnifiedCollectionItem,
|
||||
createLogger,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
readonly playlist?: { readonly items?: Channel[] };
|
||||
@@ -74,6 +82,7 @@ export class StreamResolverService {
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly portalRepair = inject(StalkerPortalRepairService);
|
||||
private readonly logger = createLogger('StreamResolver');
|
||||
private readonly m3uEpgTimeoutMs = 3000;
|
||||
private readonly portalEpgTimeoutMs = 10000;
|
||||
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
|
||||
@@ -330,18 +339,84 @@ export class StreamResolverService {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlistId)
|
||||
)) as Playlist | undefined;
|
||||
// The playlist row wins whenever it exists: a repaired endpoint or an
|
||||
// edited MAC must beat the snapshot a favorite persisted, and the
|
||||
// session token is negotiated for the ROW's identity — pairing a fresh
|
||||
// token with a stale MAC cookie is exactly the mismatch the identity
|
||||
// fingerprint exists to prevent. The item's own coordinates are the
|
||||
// fallback for a playlist that no longer exists.
|
||||
const currentPlaylist = playlist
|
||||
? this.portalRepair.applyOverride(playlist)
|
||||
: undefined;
|
||||
const portalUrl =
|
||||
item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
|
||||
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(
|
||||
currentPlaylist?.portalUrl ??
|
||||
currentPlaylist?.url ??
|
||||
item.stalkerPortalUrl ??
|
||||
'';
|
||||
const macAddress =
|
||||
currentPlaylist?.macAddress ?? item.stalkerMacAddress ?? '';
|
||||
// Favorites and Recently Viewed persist the raw catalog row, so the
|
||||
// temporary-link flags travel with the item and this route makes the
|
||||
// same decision the portal views make: an unflagged, directly playable
|
||||
// `cmd` plays as-is and never mints a 5 s link. An item that carries
|
||||
// no row snapshot (router state holds only the projection) gets no
|
||||
// verdict — except radio, whose directly usable commands have always
|
||||
// played as-is, so it keeps behaving like a row without flags.
|
||||
const snapshot = item.stalkerItem as StalkerLinkFlagSource | undefined;
|
||||
// Radio keeps its historical rule: a directly usable command plays as
|
||||
// as-is. That has to key off flag EVIDENCE, not merely a present
|
||||
// snapshot — a radio row persisted before the flags were carried has a
|
||||
// snapshot that lacks them, and testing presence alone would skip this
|
||||
// fallback and start minting for exactly those rows. Mirrors
|
||||
// `withStalkerPlayer`'s radio branch; the two must not drift.
|
||||
const linkFlags =
|
||||
item.radio === 'true' && !hasStalkerLinkFlagEvidence(snapshot)
|
||||
? {
|
||||
...(snapshot ?? {}),
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
}
|
||||
: snapshot;
|
||||
const staticUrl = resolveStalkerStaticPlaybackUrl(
|
||||
linkFlags,
|
||||
item.stalkerCmd ?? ''
|
||||
);
|
||||
if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) {
|
||||
return this.buildStalkerPlayback(item, playlist, {
|
||||
macAddress,
|
||||
portalUrl,
|
||||
streamUrl: normalizedCmd,
|
||||
});
|
||||
if (staticUrl) {
|
||||
// Skipping `create_link` also skips the only authenticated
|
||||
// request this route used to make, and it was what warmed the
|
||||
// session. Tokens live in memory only, so on a cold start from
|
||||
// global Favorites/Recently Viewed a same-host stream gated on
|
||||
// the portal Bearer token would get headers without one and 403.
|
||||
// `ensureToken` performs the handshake + get_profile — and
|
||||
// validates the identity the cached token was negotiated for,
|
||||
// which the raw `getCachedToken` below cannot — without minting a
|
||||
// link; a simple portal returns null immediately. The raw row goes
|
||||
// in: the helper applies the repair override itself, exactly as
|
||||
// `executeStalkerRequest` does on the branch below.
|
||||
//
|
||||
// Classified BEFORE authenticating: a foreign-host stream never
|
||||
// needs the session, and warming it anyway would stall playback
|
||||
// behind a handshake against a portal that may be slow or offline
|
||||
// while the CDN is perfectly reachable.
|
||||
const servePlayback = () =>
|
||||
this.buildStalkerPlayback(item, playlist, {
|
||||
macAddress,
|
||||
portalUrl,
|
||||
streamUrl: staticUrl,
|
||||
isLive: item.radio === 'true' ? undefined : true,
|
||||
});
|
||||
|
||||
if (!isStalkerStreamCredentialSafe(portalUrl, staticUrl)) {
|
||||
return servePlayback();
|
||||
}
|
||||
|
||||
// Portal-owned with no usable session would be served knowing it
|
||||
// will 401, so fall through to `create_link` instead — it mints a
|
||||
// URL that carries its own token and is the only path that can
|
||||
// observe a failure and trigger the lazy portal repair.
|
||||
if (await this.warmStalkerSession(playlist)) {
|
||||
return servePlayback();
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = item.radio === 'true' ? 'radio' : 'itv';
|
||||
@@ -404,6 +479,25 @@ export class StreamResolverService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish the portal session a static stream may still be gated on.
|
||||
* Shares the single primitive with the store's playback path so the two
|
||||
* routes cannot drift apart on when a session is required.
|
||||
*/
|
||||
private async warmStalkerSession(
|
||||
playlist: Playlist | undefined
|
||||
): Promise<boolean> {
|
||||
return ensureStalkerSession(
|
||||
{
|
||||
dataService: this.dataService,
|
||||
stalkerSession: this.stalkerSession,
|
||||
portalRepair: this.portalRepair,
|
||||
},
|
||||
playlist,
|
||||
this.logger
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The collection routes must hand players the SAME portal header set the
|
||||
* Stalker live layout builds — an auth-gated stream opened from Favorites
|
||||
@@ -412,7 +506,7 @@ export class StreamResolverService {
|
||||
* them to the stream origin; foreign hosts get the credential-free
|
||||
* profile from the shared classifier).
|
||||
*/
|
||||
private buildStalkerPlayback(
|
||||
private async buildStalkerPlayback(
|
||||
item: UnifiedCollectionItem,
|
||||
playlist: Playlist | undefined,
|
||||
resolved: {
|
||||
@@ -421,24 +515,51 @@ export class StreamResolverService {
|
||||
streamUrl: string;
|
||||
isLive?: boolean;
|
||||
}
|
||||
): ResolvedPortalPlayback {
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
// The item may carry portal/mac overrides for playlists that no
|
||||
// longer exist; the builder only reads header-relevant fields.
|
||||
//
|
||||
// The override is applied to the ROW, not just folded in as the two
|
||||
// resolved coordinates: a repair rewrites the portal MODE as well as
|
||||
// the URL, and a playlist repaired from simple to full would otherwise
|
||||
// keep its stale `isFullStalkerPortal: false` here. The request that
|
||||
// just ran adopted a token under the repaired mode, but the mode-aware
|
||||
// token resolver below would read the stale flag and hand back none —
|
||||
// emitting a same-host gated stream without the Bearer header on the
|
||||
// very playback the repair existed to rescue.
|
||||
const headerPlaylist = {
|
||||
...(playlist ?? {}),
|
||||
...(playlist ? this.portalRepair.applyOverride(playlist) : {}),
|
||||
macAddress: resolved.macAddress,
|
||||
portalUrl: resolved.portalUrl,
|
||||
} as Playlist;
|
||||
const token = this.stalkerSession.getCachedToken(item.playlistId);
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
headerPlaylist,
|
||||
resolved.streamUrl
|
||||
);
|
||||
// Classified before authenticating, for the same reason the static
|
||||
// branch classifies first: the header builder gives a foreign host the
|
||||
// credential-free profile, so a token obtained here would be discarded
|
||||
// — after stalling playback behind a handshake against a portal that
|
||||
// may be slow or offline while the CDN is perfectly reachable.
|
||||
//
|
||||
// When it IS needed, the token is resolved from `headerPlaylist`
|
||||
// rather than the row it came from: those are the exact coordinates
|
||||
// the headers claim, so the bearer token and the MAC cookie cannot end
|
||||
// up bound to a different endpoint than the one they are sent to. A
|
||||
// repair override that moved the endpoint is the live case — it
|
||||
// reaches `resolved.portalUrl` but not the raw row, and every other
|
||||
// session consumer (`ensureStalkerSession`, `executeStalkerRequest`)
|
||||
// already authenticates against the override, so this also stops the
|
||||
// resolver from keying the session cache differently and re-shaking.
|
||||
const token =
|
||||
playlist && !crossOriginStream
|
||||
? await this.resolveStalkerPlaybackToken(headerPlaylist)
|
||||
: null;
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
headerPlaylist,
|
||||
token,
|
||||
resolved.streamUrl
|
||||
);
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
headerPlaylist,
|
||||
resolved.streamUrl
|
||||
);
|
||||
const portalOrigin = getStalkerPortalOrigin(headerPlaylist);
|
||||
|
||||
return {
|
||||
@@ -457,6 +578,40 @@ export class StreamResolverService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Bearer token a full portal's stream needs.
|
||||
*
|
||||
* A direct-URL radio favorite skips `create_link` entirely, so on a cold
|
||||
* session nothing has authenticated yet and the in-memory cache is empty —
|
||||
* a same-host Bearer-gated stream would then 403 in the built-in audio
|
||||
* player. `ensureToken()` re-presents the persisted token instead, which
|
||||
* is a single idempotent handshake rather than a full re-auth. Failures
|
||||
* stay non-fatal: many portals do not gate the stream itself.
|
||||
*/
|
||||
private async resolveStalkerPlaybackToken(
|
||||
playlist: Playlist | undefined
|
||||
): Promise<string | null> {
|
||||
// The shared mode contract, not the raw flag: a legacy row with an
|
||||
// absent flag but a canonical URL IS a full portal, and reading the
|
||||
// property directly would skip authentication for it — a restored
|
||||
// older backup opens a direct-URL radio favorite with no Bearer.
|
||||
if (!playlist || !isFullStalkerPortalPlaylist(playlist)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Always through ensureToken, never the raw cache accessor: that one
|
||||
// skips the endpoint/identity/credential check, so after an edit this
|
||||
// playback path would put the previous account's token into the
|
||||
// stream headers. ensureToken returns the cached token when it is
|
||||
// still valid for this playlist, so a warm session costs nothing.
|
||||
try {
|
||||
const { token } = await this.stalkerSession.ensureToken(playlist);
|
||||
return token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildStalkerRadioChannel(
|
||||
item: UnifiedCollectionItem,
|
||||
playback: ResolvedPortalPlayback
|
||||
@@ -1168,10 +1323,6 @@ export class StreamResolverService {
|
||||
}));
|
||||
}
|
||||
|
||||
private isHttpUrl(value: string): boolean {
|
||||
return value.startsWith('http://') || value.startsWith('https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* All keys a manual mapping for this channel may have been saved under:
|
||||
* the playlist-scoped Xtream key first, then the M3U lookup keys.
|
||||
|
||||
@@ -87,10 +87,22 @@ export interface StalkerPortalCatalogFacade<
|
||||
addToFavorites(item: Record<string, unknown>, onDone?: () => void): void;
|
||||
removeFromFavorites(favoriteId: string, onDone?: () => void): void;
|
||||
fetchMovieFileId(itemId: string): Promise<string | null>;
|
||||
/**
|
||||
* `linkFlags` carries the catalog row's `use_http_tmp_link` /
|
||||
* `use_load_balancing`; without it the portal is always asked for a
|
||||
* temporary link.
|
||||
*
|
||||
* The shape is spelled out rather than imported as `StalkerLinkFlagSource`
|
||||
* on purpose: this lib is `type:util`/`domain:portal-shared` and may not
|
||||
* depend on `portal-stalker-data-access` (`type:data-access`/`domain:stalker`)
|
||||
* — the Nx module-boundary rule rejects that edge.
|
||||
*/
|
||||
fetchLinkToPlay(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string
|
||||
cmd: string,
|
||||
series?: number,
|
||||
linkFlags?: { use_http_tmp_link?: unknown; use_load_balancing?: unknown }
|
||||
): Promise<string>;
|
||||
resolveVodPlayback(
|
||||
cmd?: string,
|
||||
|
||||
@@ -2,6 +2,10 @@ export * from './lib/models';
|
||||
export * from './lib/stores';
|
||||
export * from './lib/stalker-account-info.service';
|
||||
export * from './lib/stalker-content-types';
|
||||
export * from './lib/stalker-portal-error';
|
||||
export * from './lib/stalker-response-classification';
|
||||
export * from './lib/stalker-session-store';
|
||||
export * from './lib/stalker-watchdog.controller';
|
||||
export * from './lib/stalker-itv-cache.service';
|
||||
export * from './lib/stalker-live-playback.utils';
|
||||
export * from './lib/stalker-portal-discovery.service';
|
||||
|
||||
@@ -17,6 +17,13 @@ export interface StalkerVodSource {
|
||||
cmd?: string;
|
||||
series?: unknown[];
|
||||
has_files?: number;
|
||||
/**
|
||||
* Temporary-link flags the portal sets on a row. Carried through every
|
||||
* normalizer because playback and downloads read them to decide whether
|
||||
* `create_link` is required — dropping one fails open.
|
||||
*/
|
||||
use_http_tmp_link?: unknown;
|
||||
use_load_balancing?: unknown;
|
||||
is_series?: boolean | number | string | null | undefined;
|
||||
video_id?: string;
|
||||
category_id?: string;
|
||||
@@ -47,6 +54,9 @@ export interface StalkerSelectedVodItem extends StalkerVodDetails {
|
||||
is_series?: true;
|
||||
video_id?: string;
|
||||
category_id?: string;
|
||||
/** See {@link StalkerVodSource.use_http_tmp_link}. */
|
||||
use_http_tmp_link?: unknown;
|
||||
use_load_balancing?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import type { DataService } from '@iptvnator/services';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerAuthApi } from './stalker-auth.api';
|
||||
|
||||
/**
|
||||
* Client-side mirror of the mock server's `auth-handlers.spec.ts`: the same
|
||||
* documented flows — status 2 → do_auth → profile retry, blocked profiles,
|
||||
* idempotent token reuse, not_valid propagation — asserted against the
|
||||
* requests the client actually sends.
|
||||
*/
|
||||
describe('StalkerAuthApi', () => {
|
||||
const portalUrl =
|
||||
'https://portal.example.com/stalker_portal/server/load.php';
|
||||
const macAddress = '00:1A:79:AA:BB:CC';
|
||||
|
||||
let sendIpcEvent: jest.Mock;
|
||||
let api: StalkerAuthApi;
|
||||
|
||||
const logger = {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as ReturnType<typeof createLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: {
|
||||
subtle: {
|
||||
digest: jest.fn(
|
||||
async () => new Uint8Array(20).fill(1).buffer
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
sendIpcEvent = jest.fn();
|
||||
api = new StalkerAuthApi(
|
||||
{ sendIpcEvent } as unknown as DataService,
|
||||
logger
|
||||
);
|
||||
});
|
||||
|
||||
function callsByAction(action: string) {
|
||||
return sendIpcEvent.mock.calls.filter(
|
||||
(call) => call[1].params.action === action
|
||||
);
|
||||
}
|
||||
|
||||
it('walks the login-required flow: status 2 -> do_auth -> profile retry', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1', not_valid: 0 },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
js: { status: 2, template: 'auth', info: 'Login required' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: true })
|
||||
.mockResolvedValueOnce({
|
||||
js: { status: 0, watchdog_timeout: 120, timeslot: 15 },
|
||||
});
|
||||
|
||||
const result = await api.authenticate(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{ deviceId1: 'DEV-1', deviceId2: 'DEV-2' },
|
||||
{ credentials: { username: 'user', password: 'secret' } }
|
||||
);
|
||||
|
||||
expect(result.token).toBe('TOKEN-1');
|
||||
expect(result.watchdogTimeoutSeconds).toBe(120);
|
||||
expect(result.timeslotSeconds).toBe(15);
|
||||
|
||||
// First profile request is NOT the second auth step.
|
||||
const profiles = callsByAction('get_profile');
|
||||
expect(profiles).toHaveLength(2);
|
||||
expect(profiles[0][1].params.auth_second_step).toBe('0');
|
||||
// The retry after do_auth is.
|
||||
expect(profiles[1][1].params.auth_second_step).toBe('1');
|
||||
|
||||
// do_auth carries the credentials and the device identity.
|
||||
const doAuth = callsByAction('do_auth');
|
||||
expect(doAuth).toHaveLength(1);
|
||||
expect(doAuth[0][1].params).toEqual(
|
||||
expect.objectContaining({
|
||||
login: 'user',
|
||||
password: 'secret',
|
||||
device_id: 'DEV-1',
|
||||
device_id2: 'DEV-2',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws login-required when the portal wants credentials and none are stored', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 2 } });
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'login-required',
|
||||
});
|
||||
// Empty credentials are never sent to the billing script.
|
||||
expect(callsByAction('do_auth')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('throws login-rejected when do_auth answers {js:false}', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 2 } })
|
||||
.mockResolvedValueOnce({ js: false });
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, {
|
||||
credentials: { username: 'user', password: 'wrong' },
|
||||
})
|
||||
).rejects.toMatchObject({ kind: 'login-rejected' });
|
||||
});
|
||||
|
||||
it('throws login-rejected when the profile still demands a login after do_auth', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 2 } })
|
||||
.mockResolvedValueOnce({ js: true })
|
||||
.mockResolvedValueOnce({ js: { status: 2 } });
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, {
|
||||
credentials: { username: 'user', password: 'secret' },
|
||||
})
|
||||
).rejects.toMatchObject({ kind: 'login-rejected' });
|
||||
});
|
||||
|
||||
it('carries the portal explanation out of every login refusal', async () => {
|
||||
// The actionable sentence ("wrong password", "subscription expired")
|
||||
// rides along with the refusal, and the dialog renders it after the
|
||||
// generic line. Each exit must read the response IN HAND: the retry
|
||||
// explains its own rejection, and quoting the first profile back
|
||||
// would describe a request that already succeeded.
|
||||
const handshake = { js: { token: 'TOKEN-1', random: 'r1' } };
|
||||
const credentials = { username: 'user', password: 'secret' };
|
||||
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce(handshake)
|
||||
.mockResolvedValueOnce({
|
||||
js: { status: 2, msg: 'Enter your subscriber login' },
|
||||
});
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
kind: 'login-required',
|
||||
portalText: 'Enter your subscriber login',
|
||||
});
|
||||
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce(handshake)
|
||||
.mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } })
|
||||
.mockResolvedValueOnce({ js: false });
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, { credentials })
|
||||
).rejects.toMatchObject({
|
||||
kind: 'login-rejected',
|
||||
portalText: 'Login needed',
|
||||
});
|
||||
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce(handshake)
|
||||
.mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } })
|
||||
.mockResolvedValueOnce({ js: true })
|
||||
.mockResolvedValueOnce({
|
||||
js: { status: 2, block_msg: 'Subscription expired' },
|
||||
});
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, { credentials })
|
||||
).rejects.toMatchObject({
|
||||
kind: 'login-rejected',
|
||||
// The retry's text, not the first profile's.
|
||||
portalText: 'Subscription expired',
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes a blocked profile into the portal explanation', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
js: {
|
||||
status: 1,
|
||||
msg: 'device conflict - device_id mismatch',
|
||||
block_msg: 'Your STB is damaged.<br/> Call the provider.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
kind: 'blocked',
|
||||
portalText:
|
||||
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a bare {status:1} profile as refused', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 1 } });
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({ kind: 'blocked' });
|
||||
});
|
||||
|
||||
it('propagates the handshake not_valid flag into not_valid_token', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'NEW-TOKEN', random: 'r1', not_valid: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 0 } });
|
||||
|
||||
await api.authenticate(portalUrl, macAddress, {}, {
|
||||
storedToken: 'STALE-TOKEN',
|
||||
});
|
||||
|
||||
const profile = callsByAction('get_profile')[0][1];
|
||||
expect(profile.params.not_valid_token).toBe('1');
|
||||
});
|
||||
|
||||
it('re-presents a stored token and skips get_profile when it comes back unchanged', async () => {
|
||||
sendIpcEvent.mockResolvedValueOnce({
|
||||
js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 0 },
|
||||
});
|
||||
|
||||
const result = await api.authenticate(portalUrl, macAddress, {}, {
|
||||
storedToken: 'STORED-TOKEN',
|
||||
skipProfileWhenReused: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
token: 'STORED-TOKEN',
|
||||
reusedStoredToken: true,
|
||||
});
|
||||
expect(callsByAction('handshake')[0][1].params.token).toBe(
|
||||
'STORED-TOKEN'
|
||||
);
|
||||
expect(callsByAction('get_profile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('runs the full profile when the portal replaces a stale stored token', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'FRESH-TOKEN', random: 'r1', not_valid: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 0 } });
|
||||
|
||||
const result = await api.authenticate(portalUrl, macAddress, {}, {
|
||||
storedToken: 'STALE-TOKEN',
|
||||
skipProfileWhenReused: true,
|
||||
});
|
||||
|
||||
expect(result.token).toBe('FRESH-TOKEN');
|
||||
expect(result.reusedStoredToken).toBe(false);
|
||||
expect(callsByAction('get_profile')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not reuse an echoed token the portal flagged not_valid', async () => {
|
||||
// `not_valid: 1` is the portal saying the presented token is not a
|
||||
// live session. Adopting it because the string matched would be
|
||||
// unrecoverable: nothing writes a replacement back, so every later
|
||||
// start would re-present the same dead token.
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 0 } });
|
||||
|
||||
const result = await api.authenticate(portalUrl, macAddress, {}, {
|
||||
storedToken: 'STORED-TOKEN',
|
||||
skipProfileWhenReused: true,
|
||||
});
|
||||
|
||||
expect(result.reusedStoredToken).toBe(false);
|
||||
expect(callsByAction('get_profile')).toHaveLength(1);
|
||||
expect(callsByAction('get_profile')[0][1].params.not_valid_token).toBe(
|
||||
'1'
|
||||
);
|
||||
});
|
||||
|
||||
it('decodes a stringified status 2 into the login flow', async () => {
|
||||
// Portals routinely stringify numeric fields — the same reason
|
||||
// watchdog_timeout/timeslot accept strings. A strict === would read
|
||||
// "2" as a healthy profile and skip do_auth entirely.
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: '2' } })
|
||||
.mockResolvedValueOnce({ js: true })
|
||||
.mockResolvedValueOnce({ js: { status: '0' } });
|
||||
|
||||
const result = await api.authenticate(portalUrl, macAddress, {}, {
|
||||
credentials: { username: 'user', password: 'secret' },
|
||||
});
|
||||
|
||||
expect(result.token).toBe('TOKEN-1');
|
||||
expect(callsByAction('do_auth')).toHaveLength(1);
|
||||
expect(
|
||||
callsByAction('get_profile')[1][1].params.auth_second_step
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
it('decodes a stringified status 1 as a refusal', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: '1' } });
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({ kind: 'blocked' });
|
||||
});
|
||||
|
||||
it('never sends get_profile once the caller abandoned the attempt', async () => {
|
||||
// The harm this prevents: `get_profile` is what adopts a token for
|
||||
// the MAC portal-side, so an abandoned discovery attempt reaching it
|
||||
// after another candidate authenticated would invalidate that
|
||||
// healthy candidate's token.
|
||||
const abandon = new AbortController();
|
||||
sendIpcEvent.mockImplementationOnce(async () => {
|
||||
abandon.abort();
|
||||
return { js: { token: 'TOKEN-1', random: 'r1' } };
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, {
|
||||
signal: abandon.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
|
||||
|
||||
expect(callsByAction('handshake')).toHaveLength(1);
|
||||
expect(callsByAction('get_profile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not even handshake when already abandoned', async () => {
|
||||
const abandon = new AbortController();
|
||||
abandon.abort();
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, {
|
||||
signal: abandon.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
|
||||
|
||||
expect(sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rechecks the abort after the async prehash, not just before the call', async () => {
|
||||
// getProfile awaits generatePrehash(); an abort landing during that
|
||||
// await would otherwise still let the adopting request go out.
|
||||
const abandon = new AbortController();
|
||||
const digest = globalThis.crypto.subtle.digest as jest.Mock;
|
||||
digest.mockImplementation(async () => {
|
||||
abandon.abort();
|
||||
return new Uint8Array(20).fill(1).buffer;
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.getProfile(portalUrl, macAddress, 'TOKEN-1', {}, 'r1', {
|
||||
signal: abandon.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
|
||||
|
||||
expect(sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the status-2 login flow when abandoned mid-way', async () => {
|
||||
const abandon = new AbortController();
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: { token: 'TOKEN-1', random: 'r1' } })
|
||||
.mockImplementationOnce(async () => {
|
||||
abandon.abort();
|
||||
return { js: { status: 2 } };
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress, {}, {
|
||||
credentials: { username: 'user', password: 'secret' },
|
||||
signal: abandon.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ name: 'StalkerAuthAbortedError' });
|
||||
|
||||
expect(callsByAction('do_auth')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('classifies a transport auth-failure marker during get_profile', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stalkerAuthFailure: 'Unauthorized request.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
kind: 'auth-failed',
|
||||
failureBody: 'Unauthorized request.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
import type { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
extractStalkerAuthFailureBody,
|
||||
STALKER_REQUEST,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
normalizeStalkerPortalIdentity,
|
||||
type StalkerPortalIdentity,
|
||||
} from './stalker-identity.utils';
|
||||
import {
|
||||
combineStalkerPortalMessages,
|
||||
StalkerPortalError,
|
||||
} from './stalker-portal-error';
|
||||
|
||||
export interface StalkerHandshakeResponse {
|
||||
js: {
|
||||
token: string;
|
||||
not_valid?: number;
|
||||
random?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StalkerProfileResponse {
|
||||
js: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
mac?: string;
|
||||
status?: number;
|
||||
msg?: string;
|
||||
block_msg?: string;
|
||||
/** Watchdog cadence in seconds, echoed by the portal (default 120). */
|
||||
watchdog_timeout?: number | string;
|
||||
/** Per-user jitter in seconds so watchdog pings do not align. */
|
||||
timeslot?: number | string;
|
||||
account_info?: {
|
||||
login?: string;
|
||||
expire_date?: number;
|
||||
tariff_plan_name?: string;
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface StalkerAuthConfirmationResponse {
|
||||
js?: boolean;
|
||||
}
|
||||
|
||||
export interface StalkerPortalCredentials {
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface StalkerAuthenticateOptions {
|
||||
/**
|
||||
* Previously persisted session token. The handshake is idempotent: it is
|
||||
* re-presented, and the portal returns it unchanged while it is still the
|
||||
* MAC's session token.
|
||||
*/
|
||||
storedToken?: string;
|
||||
/** Login/password for portals that answer `get_profile` with status 2. */
|
||||
credentials?: StalkerPortalCredentials;
|
||||
/**
|
||||
* Skip the `get_profile` round trip when the stored token came back
|
||||
* unchanged — an unchanged token is already an adopted session.
|
||||
*/
|
||||
skipProfileWhenReused?: boolean;
|
||||
/**
|
||||
* Abandons the flow between portal calls.
|
||||
*
|
||||
* This is what a timed-out discovery attempt needs: `get_profile` is the
|
||||
* call that adopts a token for the MAC portal-side, so an abandoned
|
||||
* attempt reaching it AFTER another candidate authenticated would
|
||||
* invalidate that healthy candidate's token. Checking the signal before
|
||||
* each call stops the request from being sent, which is the only thing
|
||||
* that prevents it — a request already on the wire is processed by the
|
||||
* server whether or not the client tears the socket down.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface StalkerAuthenticationResult {
|
||||
token: string;
|
||||
accountInfo?: StalkerProfileResponse['js']['account_info'];
|
||||
/**
|
||||
* Raw envelope so callers can apply their own failure checks — endpoint
|
||||
* discovery rejects a candidate whose `get_profile` refuses even though
|
||||
* the handshake succeeded. Absent when the stored token was reused and
|
||||
* no profile was fetched.
|
||||
*/
|
||||
profileResponse?: StalkerProfileResponse;
|
||||
/** True when the stored token was accepted and `get_profile` was skipped. */
|
||||
reusedStoredToken?: boolean;
|
||||
watchdogTimeoutSeconds?: number;
|
||||
timeslotSeconds?: number;
|
||||
}
|
||||
|
||||
interface StalkerHandshakeOutcome {
|
||||
token: string;
|
||||
random: string;
|
||||
/** The portal flagged the presented stored token as no longer valid. */
|
||||
notValid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA1 hash using native Web Crypto API
|
||||
* Produces correct 40-character hex hash matching real Stalker clients
|
||||
*/
|
||||
async function sha1(str: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates SHA1 prehash from MAC address
|
||||
* This must match what real Stalker clients send
|
||||
*/
|
||||
async function generatePrehash(macAddress: string): Promise<string> {
|
||||
// Use MAC address with colons, uppercase - this is what most clients use
|
||||
const str = macAddress.toUpperCase();
|
||||
return (await sha1(str)).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random string for metrics
|
||||
*/
|
||||
function generateRandom(): string {
|
||||
const chars = 'abcdef0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 40; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Raised when a caller abandoned the flow between portal calls. */
|
||||
export class StalkerAuthAbortedError extends Error {
|
||||
constructor() {
|
||||
super('Stalker authentication was aborted');
|
||||
this.name = 'StalkerAuthAbortedError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new StalkerAuthAbortedError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws when a response is one of the portal's plain-text auth-failure
|
||||
* bodies (already classified by the transport, or still raw on legacy paths).
|
||||
*/
|
||||
function assertNotAuthFailure(response: unknown): void {
|
||||
const failureBody = extractStalkerAuthFailureBody(response);
|
||||
if (failureBody) {
|
||||
throw new StalkerPortalError('auth-failed', failureBody, failureBody);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw Stalker authentication requests plus the documented login flow.
|
||||
*
|
||||
* Protocol (Stalker 4.9.35): `handshake` issues an idempotent token;
|
||||
* `get_profile` turns it into a session and decodes as: full profile = OK,
|
||||
* `status: 1` = blocked (see `msg`/`block_msg`), `status: 2` = login/password
|
||||
* required → `do_auth`, then `get_profile` again with `auth_second_step=1`.
|
||||
*/
|
||||
export class StalkerAuthApi {
|
||||
constructor(
|
||||
private readonly dataService: DataService,
|
||||
private readonly logger: ReturnType<typeof createLogger>
|
||||
) {}
|
||||
|
||||
async performHandshake(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity = {},
|
||||
storedToken?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StalkerHandshakeOutcome> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
const prehash = await generatePrehash(macAddress);
|
||||
|
||||
const params: Record<string, string> = {
|
||||
type: 'stb',
|
||||
action: 'handshake',
|
||||
// Re-presenting a persisted token is how a real client resumes a
|
||||
// session: an idempotent portal returns it unchanged.
|
||||
token: storedToken ?? '',
|
||||
prehash,
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
try {
|
||||
// Re-checked here rather than only at the call site: the prehash
|
||||
// above is async, so an abort can land while it is being
|
||||
// computed and the request would still go out.
|
||||
assertNotAborted(signal);
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { serialNumber: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
assertNotAuthFailure(response);
|
||||
|
||||
if (response?.js?.token) {
|
||||
return {
|
||||
token: response.js.token,
|
||||
random: response.js.random || generateRandom(),
|
||||
notValid: Number(response.js.not_valid ?? 0) === 1,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.error('No token in response');
|
||||
throw new Error('Handshake failed: No token received');
|
||||
} catch (error) {
|
||||
this.logger.error('Handshake error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets account profile information to validate the portal and check
|
||||
* subscription. `authSecondStep` is set only on the retry after `do_auth`;
|
||||
* `notValidToken` propagates the handshake's `not_valid` flag.
|
||||
*/
|
||||
async getProfile(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
token: string,
|
||||
identity: StalkerPortalIdentity,
|
||||
handshakeRandom: string,
|
||||
options: {
|
||||
authSecondStep?: boolean;
|
||||
notValidToken?: boolean;
|
||||
signal?: AbortSignal;
|
||||
} = {}
|
||||
): Promise<StalkerProfileResponse> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
|
||||
// Build metrics JSON matching working app
|
||||
const metrics: Record<string, string> = {
|
||||
mac: macAddress,
|
||||
model: 'MAG250',
|
||||
type: 'STB',
|
||||
random: handshakeRandom,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { sn: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Generate prehash for get_profile (same as handshake)
|
||||
const prehash = await generatePrehash(macAddress);
|
||||
|
||||
const params: Record<string, string> = {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
hd: '1',
|
||||
not_valid_token: options.notValidToken ? '1' : '0',
|
||||
video_out: 'hdmi',
|
||||
auth_second_step: options.authSecondStep ? '1' : '0',
|
||||
num_banks: '2',
|
||||
metrics: JSON.stringify(metrics),
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { sn: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
...(normalizedIdentity.deviceId1
|
||||
? { device_id: normalizedIdentity.deviceId1 }
|
||||
: {}),
|
||||
...(normalizedIdentity.deviceId2
|
||||
? { device_id2: normalizedIdentity.deviceId2 }
|
||||
: {}),
|
||||
...(normalizedIdentity.signature1
|
||||
? { signature: normalizedIdentity.signature1 }
|
||||
: {}),
|
||||
...(normalizedIdentity.signature2
|
||||
? { signature2: normalizedIdentity.signature2 }
|
||||
: {}),
|
||||
prehash: prehash,
|
||||
stb_type: '',
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
try {
|
||||
// The last possible moment before the call that adopts the MAC's
|
||||
// token portal-side — the prehash above is async, so the caller's
|
||||
// pre-check can be stale by now.
|
||||
assertNotAborted(options.signal);
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { serialNumber: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
assertNotAuthFailure(response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.logger.error('Get profile error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs `do_auth` — the login/password step behind `get_profile`
|
||||
* status 2. Returns the portal's bare boolean verdict.
|
||||
*/
|
||||
async doAuth(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
token: string,
|
||||
credentials: StalkerPortalCredentials,
|
||||
identity: StalkerPortalIdentity = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
const params: Record<string, string> = {
|
||||
type: 'stb',
|
||||
action: 'do_auth',
|
||||
login: credentials.username ?? '',
|
||||
password: credentials.password ?? '',
|
||||
...(normalizedIdentity.deviceId1
|
||||
? { device_id: normalizedIdentity.deviceId1 }
|
||||
: {}),
|
||||
...(normalizedIdentity.deviceId2
|
||||
? { device_id2: normalizedIdentity.deviceId2 }
|
||||
: {}),
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
try {
|
||||
assertNotAborted(signal);
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { serialNumber: normalizedIdentity.serialNumber }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
assertNotAuthFailure(response);
|
||||
|
||||
// do_auth returns { js: true } on success
|
||||
return response?.js === true;
|
||||
} catch (error) {
|
||||
this.logger.error('do_auth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full authentication flow: handshake → (optional token reuse) →
|
||||
* get_profile → (status 2 → do_auth → get_profile with
|
||||
* auth_second_step=1) → decoded profile.
|
||||
*/
|
||||
async authenticate(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity = {},
|
||||
options: StalkerAuthenticateOptions = {}
|
||||
): Promise<StalkerAuthenticationResult> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
|
||||
assertNotAborted(options.signal);
|
||||
const handshake = await this.performHandshake(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
normalizedIdentity,
|
||||
options.storedToken,
|
||||
options.signal
|
||||
);
|
||||
|
||||
// An unchanged stored token is already an adopted session — the
|
||||
// profile round trip is what token persistence saves. `not_valid`
|
||||
// vetoes that shortcut: it is the portal saying the presented token
|
||||
// is not a live session, and adopting it anyway would be
|
||||
// unrecoverable — nothing writes a new token back, so every later
|
||||
// start would re-present the same dead one.
|
||||
if (
|
||||
options.skipProfileWhenReused &&
|
||||
options.storedToken &&
|
||||
!handshake.notValid &&
|
||||
handshake.token === options.storedToken
|
||||
) {
|
||||
return { token: handshake.token, reusedStoredToken: true };
|
||||
}
|
||||
|
||||
// The abandoned-attempt guard that matters: this is the call that
|
||||
// adopts the token for the MAC portal-side.
|
||||
assertNotAborted(options.signal);
|
||||
const profile = await this.getProfile(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
handshake.token,
|
||||
normalizedIdentity,
|
||||
handshake.random,
|
||||
{
|
||||
authSecondStep: false,
|
||||
notValidToken: handshake.notValid,
|
||||
signal: options.signal,
|
||||
}
|
||||
);
|
||||
|
||||
// The envelope the login flow actually settled on: after a status-2
|
||||
// `do_auth` that is the RETRIED profile, not the first one.
|
||||
const profileResponse = await this.resolveProfile(
|
||||
profile,
|
||||
portalUrl,
|
||||
macAddress,
|
||||
handshake,
|
||||
normalizedIdentity,
|
||||
options
|
||||
);
|
||||
const resolved = profileResponse?.js;
|
||||
|
||||
return {
|
||||
token: handshake.token,
|
||||
accountInfo: resolved?.account_info,
|
||||
profileResponse,
|
||||
reusedStoredToken: false,
|
||||
watchdogTimeoutSeconds: toFiniteNumber(resolved?.watchdog_timeout),
|
||||
timeslotSeconds: toFiniteNumber(resolved?.timeslot),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes `js.status` and drives the status-2 login flow. Throws a
|
||||
* `StalkerPortalError` carrying the portal's own `msg`/`block_msg` text
|
||||
* when the portal refused the session.
|
||||
*/
|
||||
private async resolveProfile(
|
||||
profile: StalkerProfileResponse,
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
handshake: StalkerHandshakeOutcome,
|
||||
identity: StalkerPortalIdentity,
|
||||
options: StalkerAuthenticateOptions
|
||||
): Promise<StalkerProfileResponse> {
|
||||
let settled = profile;
|
||||
let js = profile?.js;
|
||||
|
||||
// Portals routinely stringify numeric fields (the same reason
|
||||
// `watchdog_timeout`/`timeslot` are typed `number | string`), so a
|
||||
// strict `=== 2` would read `"2"` as a healthy profile and skip the
|
||||
// whole login flow.
|
||||
if (toFiniteNumber(js?.status) === 2) {
|
||||
// The portal's own sentence travels WITH the refusal — "wrong
|
||||
// password", "subscription expired", "contact your provider" —
|
||||
// and reading it is the whole reason these errors carry
|
||||
// `portalText`. It is read at each exit rather than once up
|
||||
// front because `js` advances to the retry's response below: the
|
||||
// second refusal explains itself, and quoting the first one back
|
||||
// would describe a request that already succeeded.
|
||||
const loginText = () =>
|
||||
combineStalkerPortalMessages(js?.msg, js?.block_msg);
|
||||
const username = options.credentials?.username?.trim() ?? '';
|
||||
const password = options.credentials?.password ?? '';
|
||||
if (!username || !password) {
|
||||
throw new StalkerPortalError('login-required', loginText());
|
||||
}
|
||||
|
||||
assertNotAborted(options.signal);
|
||||
const accepted = await this.doAuth(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
handshake.token,
|
||||
{ username, password },
|
||||
identity,
|
||||
options.signal
|
||||
);
|
||||
if (!accepted) {
|
||||
// `do_auth` answers a bare `{js: false}`, so the only text
|
||||
// available here is the profile's — which is the one that
|
||||
// asked for the login in the first place.
|
||||
throw new StalkerPortalError('login-rejected', loginText());
|
||||
}
|
||||
|
||||
assertNotAborted(options.signal);
|
||||
const retried = await this.getProfile(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
handshake.token,
|
||||
identity,
|
||||
handshake.random,
|
||||
{
|
||||
authSecondStep: true,
|
||||
notValidToken: handshake.notValid,
|
||||
signal: options.signal,
|
||||
}
|
||||
);
|
||||
settled = retried;
|
||||
js = retried?.js;
|
||||
if (toFiniteNumber(js?.status) === 2) {
|
||||
throw new StalkerPortalError('login-rejected', loginText());
|
||||
}
|
||||
}
|
||||
|
||||
const portalText = combineStalkerPortalMessages(
|
||||
js?.msg,
|
||||
js?.block_msg
|
||||
);
|
||||
|
||||
if (toFiniteNumber(js?.status) === 1 || portalText) {
|
||||
this.logger.error('Profile error:', portalText ?? 'status 1');
|
||||
throw new StalkerPortalError('blocked', portalText);
|
||||
}
|
||||
|
||||
return settled;
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,12 @@ describe('StalkerPortalDiscoveryService', () => {
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
'http://ministra.example/server/load.php',
|
||||
MAC,
|
||||
{ serialNumber: 'SN1' }
|
||||
{ serialNumber: 'SN1' },
|
||||
// Import credentials ride along for portals that answer
|
||||
// get_profile with status 2 (login/password required).
|
||||
// A per-attempt abort signal: a timed-out authentication must
|
||||
// not send its get_profile behind the next candidate's back.
|
||||
{ credentials: undefined, signal: expect.any(AbortSignal) }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -190,7 +195,10 @@ describe('StalkerPortalDiscoveryService', () => {
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
'http://gated.example/portal.php',
|
||||
MAC,
|
||||
{}
|
||||
{},
|
||||
// A per-attempt abort signal: a timed-out authentication must
|
||||
// not send its get_profile behind the next candidate's back.
|
||||
{ credentials: undefined, signal: expect.any(AbortSignal) }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -343,4 +351,153 @@ describe('StalkerPortalDiscoveryService', () => {
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('aborts a timed-out authentication before advancing to the next candidate', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
mockProbes({
|
||||
'http://slow.example/portal.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
});
|
||||
|
||||
let captured: AbortSignal | undefined;
|
||||
authenticate.mockImplementation(
|
||||
(_url, _mac, _identity, options) => {
|
||||
captured = options?.signal;
|
||||
// Hangs past the auth budget, like the real symptom.
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
);
|
||||
|
||||
const discovery = service.discover(
|
||||
'http://slow.example/c',
|
||||
MAC
|
||||
);
|
||||
// Let the probe resolve so the auth attempt actually starts.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
jest.advanceTimersByTime(65_000);
|
||||
await Promise.resolve();
|
||||
|
||||
// The abandoned attempt is cancelled, so its get_profile never
|
||||
// goes out to adopt the MAC's token behind a later candidate.
|
||||
expect(captured?.aborted).toBe(true);
|
||||
|
||||
// …and discovery does not RACE it: a request already dispatched
|
||||
// cannot be un-sent, so the run drains it (bounded) instead of
|
||||
// probing the next candidate while it may still land.
|
||||
jest.advanceTimersByTime(15_000);
|
||||
// Outcome shape is unchanged: a timed-out confirmation is still
|
||||
// reported as a refusal of that endpoint.
|
||||
await expect(discovery).resolves.toMatchObject({
|
||||
status: 'auth-rejected',
|
||||
portalUrl: 'http://slow.example/portal.php',
|
||||
});
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('drains an abandoned attempt instead of probing the next candidate', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
// Two candidates both answer "auth required", so a resolved first
|
||||
// attempt would stop the run — only a timed-out one advances.
|
||||
mockProbes({
|
||||
'http://slow.example/portal.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
'http://slow.example/server/load.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
});
|
||||
|
||||
let settleFirst: (() => void) | undefined;
|
||||
authenticate
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
settleFirst = () => reject(new Error('late'));
|
||||
})
|
||||
)
|
||||
.mockResolvedValue({ token: 'SECOND' });
|
||||
|
||||
const discovery = service.discover('http://slow.example/c', MAC);
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
jest.advanceTimersByTime(65_000);
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// The first attempt is still on the wire: the second candidate
|
||||
// must NOT have been authenticated yet, or its token could be
|
||||
// invalidated by the first one's late get_profile.
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Once it settles, the run continues immediately.
|
||||
settleFirst?.();
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(authenticate).toHaveBeenCalledTimes(2);
|
||||
await expect(discovery).resolves.toMatchObject({
|
||||
status: 'resolved',
|
||||
});
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops discovery when the drain deadline expires with the attempt still live', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
// Cancellation is cooperative: neither transport can pull a
|
||||
// request off the wire. An attempt still unsettled 80 s in may
|
||||
// yet land its `get_profile`, which adopts the MAC's token
|
||||
// portal-side — so probing on would stake the next candidate's
|
||||
// freshly issued session on a request nobody can recall.
|
||||
mockProbes({
|
||||
'http://hung.example/portal.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
'http://hung.example/server/load.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
});
|
||||
|
||||
// Never settles — not even after the drain.
|
||||
authenticate
|
||||
.mockImplementationOnce(() => new Promise(() => undefined))
|
||||
.mockResolvedValue({ token: 'SECOND' });
|
||||
|
||||
const discovery = service.discover('http://hung.example/c', MAC);
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
jest.advanceTimersByTime(65_000);
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
jest.advanceTimersByTime(15_000);
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
await expect(discovery).resolves.toMatchObject({
|
||||
status: 'auth-rejected',
|
||||
abandonedInFlight: true,
|
||||
});
|
||||
// The second candidate was never authenticated.
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { DataService } from '@iptvnator/services';
|
||||
import { STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerPortalCredentials,
|
||||
StalkerProfileResponse,
|
||||
StalkerSessionService,
|
||||
} from './stalker-session.service';
|
||||
@@ -26,6 +27,13 @@ export interface StalkerPortalEndpointResolution {
|
||||
token?: string;
|
||||
/** Account block from the classification `get_profile` (full portals only). */
|
||||
accountInfo?: StalkerProfileResponse['js']['account_info'];
|
||||
/**
|
||||
* Watchdog cadence the confirming `get_profile` advertised. Persisted at
|
||||
* import because a later start reuses the token and skips the only
|
||||
* response that carries it.
|
||||
*/
|
||||
watchdogTimeoutSeconds?: number;
|
||||
timeslotSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +45,16 @@ export interface StalkerPortalDiscoveryRejection {
|
||||
status: 'auth-rejected';
|
||||
portalUrl: string;
|
||||
error?: unknown;
|
||||
/**
|
||||
* The abandoned attempt was STILL in flight when the drain deadline
|
||||
* expired, so discovery must not advance. Cancellation is cooperative —
|
||||
* neither transport can pull a request off the wire (the PWA `fetch()`
|
||||
* takes no signal, and the Electron main process runs its HTTP request to
|
||||
* completion) — so an attempt this far past its deadline may still land a
|
||||
* `get_profile`, which adopts the MAC's token portal-side and would
|
||||
* invalidate the session a later candidate had just established.
|
||||
*/
|
||||
abandonedInFlight?: boolean;
|
||||
}
|
||||
|
||||
/** No candidate answered like a Stalker portal (host down or not a portal). */
|
||||
@@ -51,15 +69,42 @@ export type StalkerPortalDiscoveryOutcome =
|
||||
|
||||
/** Per-request guard so a hanging host cannot stall discovery forever. */
|
||||
const PROBE_TIMEOUT_MS = 20_000;
|
||||
/** authenticate() is two sequential requests; give it a matching budget. */
|
||||
const AUTH_TIMEOUT_MS = 45_000;
|
||||
/**
|
||||
* `authenticate()` is up to FOUR sequential requests once a portal answers
|
||||
* `get_profile` with status 2 — handshake, profile, `do_auth`, profile retry —
|
||||
* and the Electron transport allows each non-`create_link` call 15 s. A budget
|
||||
* that only covered two would abort a valid but slow login-required portal
|
||||
* before its final profile and report it as `auth-rejected`.
|
||||
*/
|
||||
const AUTH_TIMEOUT_MS = 4 * 15_000 + 5_000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
/**
|
||||
* How long to wait for an abandoned attempt to settle before probing the next
|
||||
* candidate.
|
||||
*
|
||||
* Aborting cannot un-send a request: if its `get_profile` was already
|
||||
* dispatched, the portal adopts that token regardless of what the client does
|
||||
* to its socket. What we CAN do is refuse to race it — advancing while it is
|
||||
* still in flight is what lets it invalidate the token the next candidate
|
||||
* negotiates. Bounded by one request budget so a genuinely hung host cannot
|
||||
* stall discovery forever; past that the risk is accepted rather than hanging.
|
||||
*/
|
||||
const ABANDONED_DRAIN_MS = 15_000;
|
||||
|
||||
function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
onTimeout?: () => void
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('Stalker portal probe timed out')),
|
||||
timeoutMs
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
// Abandon the underlying operation BEFORE advancing: the timer
|
||||
// only rejects this wrapper, and a late `get_profile` would adopt
|
||||
// the MAC's token portal-side, invalidating whatever the next
|
||||
// candidate just negotiated.
|
||||
onTimeout?.();
|
||||
reject(new Error('Stalker portal probe timed out'));
|
||||
}, timeoutMs);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
@@ -95,7 +140,8 @@ export class StalkerPortalDiscoveryService {
|
||||
async discover(
|
||||
rawUrl: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity = {}
|
||||
identity: StalkerPortalIdentity = {},
|
||||
options: { credentials?: StalkerPortalCredentials } = {}
|
||||
): Promise<StalkerPortalDiscoveryOutcome> {
|
||||
const candidates = buildStalkerEndpointCandidates(rawUrl);
|
||||
let authRejection: StalkerPortalDiscoveryRejection | null = null;
|
||||
@@ -114,11 +160,15 @@ export class StalkerPortalDiscoveryService {
|
||||
const outcome = await this.confirmFullPortal(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity
|
||||
identity,
|
||||
options.credentials
|
||||
);
|
||||
if (outcome.status === 'resolved') {
|
||||
return outcome;
|
||||
}
|
||||
if (outcome.abandonedInFlight) {
|
||||
return authRejection ?? outcome;
|
||||
}
|
||||
authRejection = authRejection ?? outcome;
|
||||
continue;
|
||||
}
|
||||
@@ -158,11 +208,17 @@ export class StalkerPortalDiscoveryService {
|
||||
const outcome = await this.confirmFullPortal(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity
|
||||
identity,
|
||||
options.credentials
|
||||
);
|
||||
if (outcome.status === 'resolved') {
|
||||
return outcome;
|
||||
}
|
||||
// An attempt still on the wire outranks further probing:
|
||||
// see `abandonedInFlight`.
|
||||
if (outcome.abandonedInFlight) {
|
||||
return authRejection ?? outcome;
|
||||
}
|
||||
// The endpoint is real but refused our credentials;
|
||||
// remember the first such endpoint in case no later
|
||||
// candidate resolves.
|
||||
@@ -184,18 +240,25 @@ export class StalkerPortalDiscoveryService {
|
||||
private async confirmFullPortal(
|
||||
candidate: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity
|
||||
identity: StalkerPortalIdentity,
|
||||
credentials?: StalkerPortalCredentials
|
||||
): Promise<
|
||||
StalkerPortalEndpointResolution | StalkerPortalDiscoveryRejection
|
||||
> {
|
||||
// Cooperative cancellation: `authenticate()` checks this before each
|
||||
// portal call, so a timed-out attempt never sends the `get_profile`
|
||||
// that would adopt the MAC's token behind the next candidate's back.
|
||||
const abandon = new AbortController();
|
||||
// Kept so a timed-out attempt can be drained rather than raced.
|
||||
const pending = this.stalkerSession.authenticate(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity,
|
||||
{ credentials, signal: abandon.signal }
|
||||
);
|
||||
try {
|
||||
const auth = await withTimeout(
|
||||
this.stalkerSession.authenticate(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity
|
||||
),
|
||||
AUTH_TIMEOUT_MS
|
||||
const auth = await withTimeout(pending, AUTH_TIMEOUT_MS, () =>
|
||||
abandon.abort()
|
||||
);
|
||||
// A handshake can hand out a token whose `get_profile` still
|
||||
// answers a structured denial (`{js:{error:'Invalid token'}}`);
|
||||
@@ -215,12 +278,41 @@ export class StalkerPortalDiscoveryService {
|
||||
isFullStalkerPortal: true,
|
||||
token: auth.token,
|
||||
accountInfo: auth.accountInfo,
|
||||
watchdogTimeoutSeconds: auth.watchdogTimeoutSeconds,
|
||||
timeslotSeconds: auth.timeslotSeconds,
|
||||
};
|
||||
} catch (error) {
|
||||
// Do not advance while the abandoned attempt may still be on the
|
||||
// wire: its `get_profile` adopts the MAC's token portal-side, so
|
||||
// racing it is exactly what invalidates the next candidate's
|
||||
// freshly issued session.
|
||||
//
|
||||
// Draining is the normal case and usually returns at once — an
|
||||
// aborted attempt settles as soon as its in-flight request errors
|
||||
// out. The deadline exists for the attempt that does not settle,
|
||||
// and reaching it is reported rather than swallowed: continuing
|
||||
// would stake a working candidate's session on a request nobody
|
||||
// can recall.
|
||||
const DRAINED = Symbol('drained');
|
||||
const outcome = await Promise.race([
|
||||
pending.then(
|
||||
() => DRAINED,
|
||||
() => DRAINED
|
||||
),
|
||||
new Promise<undefined>((resolve) =>
|
||||
setTimeout(resolve, ABANDONED_DRAIN_MS)
|
||||
),
|
||||
]);
|
||||
if (outcome !== DRAINED) {
|
||||
this.logger.warn(
|
||||
'Abandoned Stalker authentication is still in flight after the drain deadline; stopping discovery rather than racing it'
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 'auth-rejected',
|
||||
portalUrl: candidate,
|
||||
error,
|
||||
...(outcome === DRAINED ? {} : { abandonedInFlight: true }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
asStalkerPortalError,
|
||||
combineStalkerPortalMessages,
|
||||
StalkerPortalError,
|
||||
stripStalkerPortalMarkup,
|
||||
} from './stalker-portal-error';
|
||||
|
||||
describe('stripStalkerPortalMarkup', () => {
|
||||
it('strips the markup a real block_msg carries', () => {
|
||||
expect(
|
||||
stripStalkerPortalMarkup(
|
||||
'Your STB is damaged.<br/> Call the provider.'
|
||||
)
|
||||
).toBe('Your STB is damaged. Call the provider.');
|
||||
});
|
||||
|
||||
it('collapses whitespace and trims', () => {
|
||||
expect(stripStalkerPortalMarkup(' a \n b ')).toBe('a b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineStalkerPortalMessages', () => {
|
||||
it('joins msg and block_msg', () => {
|
||||
expect(
|
||||
combineStalkerPortalMessages('device conflict', 'STB damaged')
|
||||
).toBe('device conflict — STB damaged');
|
||||
});
|
||||
|
||||
it('drops empty and duplicated parts', () => {
|
||||
expect(combineStalkerPortalMessages('', undefined)).toBeUndefined();
|
||||
expect(combineStalkerPortalMessages('same', 'same')).toBe('same');
|
||||
});
|
||||
});
|
||||
|
||||
describe('asStalkerPortalError', () => {
|
||||
it('recognizes real instances', () => {
|
||||
const error = new StalkerPortalError('blocked', 'text');
|
||||
expect(asStalkerPortalError(error)).toBe(error);
|
||||
});
|
||||
|
||||
it('recognizes a structurally equivalent object across chunk boundaries', () => {
|
||||
const shaped = {
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'login-required',
|
||||
message: 'refused',
|
||||
};
|
||||
expect(asStalkerPortalError(shaped)?.kind).toBe('login-required');
|
||||
});
|
||||
|
||||
it('rejects ordinary errors and non-errors', () => {
|
||||
expect(asStalkerPortalError(new Error('nope'))).toBeNull();
|
||||
expect(asStalkerPortalError('Access denied.')).toBeNull();
|
||||
expect(asStalkerPortalError(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { StalkerAuthFailureBody } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Why a Stalker portal refused the session.
|
||||
*
|
||||
* - `login-required` — `get_profile` answered `status: 2`: the portal wants
|
||||
* `do_auth` with a login/password, and none are stored for the playlist.
|
||||
* - `login-rejected` — `do_auth` answered `{js: false}` (the operator billing
|
||||
* script refused the credentials), or the profile still demanded a login
|
||||
* after a successful `do_auth`.
|
||||
* - `blocked` — `get_profile` answered `status: 1`: the account is blocked or
|
||||
* the device identity conflicts. `msg`/`block_msg` explain why.
|
||||
* - `auth-failed` — a request came back as one of the plain-text bodies
|
||||
* (`Authorization failed.`, `Access denied.`, `Unauthorized request.`) and
|
||||
* re-authentication did not recover it.
|
||||
*/
|
||||
export type StalkerPortalErrorKind =
|
||||
| 'login-required'
|
||||
| 'login-rejected'
|
||||
| 'blocked'
|
||||
| 'auth-failed';
|
||||
|
||||
/**
|
||||
* `block_msg` routinely carries markup ("Your STB is damaged.<br/> Call the
|
||||
* provider."); strip it before the text reaches a snackbar or error view.
|
||||
*/
|
||||
export function stripStalkerPortalMarkup(text: string): string {
|
||||
return text
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines the portal's own `msg`/`block_msg` explanation into one plain-text
|
||||
* line, or returns undefined when the portal sent none.
|
||||
*/
|
||||
export function combineStalkerPortalMessages(
|
||||
msg: string | undefined,
|
||||
blockMsg: string | undefined
|
||||
): string | undefined {
|
||||
const parts = [msg, blockMsg]
|
||||
.map((part) => stripStalkerPortalMarkup(part ?? ''))
|
||||
.filter((part) => part.length > 0);
|
||||
// A duplicated msg/block_msg pair should not be echoed twice.
|
||||
const unique = parts.filter(
|
||||
(part, index) => parts.indexOf(part) === index
|
||||
);
|
||||
return unique.length > 0 ? unique.join(' — ') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A portal-explained authentication failure. `portalText` carries the server's
|
||||
* own words (already markup-stripped) when it sent any — the UI shows them
|
||||
* verbatim instead of a generic "unable to load" message.
|
||||
*/
|
||||
export class StalkerPortalError extends Error {
|
||||
constructor(
|
||||
readonly kind: StalkerPortalErrorKind,
|
||||
readonly portalText?: string,
|
||||
readonly failureBody?: StalkerAuthFailureBody
|
||||
) {
|
||||
super(
|
||||
`Stalker portal refused the session (${kind})` +
|
||||
(portalText ? `: ${portalText}` : '')
|
||||
);
|
||||
this.name = 'StalkerPortalError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognizes a StalkerPortalError across chunk boundaries: `instanceof` is
|
||||
* checked first, but a structurally equivalent object (same name + kind) is
|
||||
* accepted too so lazy-loaded consumers never mis-classify one.
|
||||
*/
|
||||
export function asStalkerPortalError(
|
||||
error: unknown
|
||||
): StalkerPortalError | null {
|
||||
if (error instanceof StalkerPortalError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { name?: unknown }).name === 'StalkerPortalError' &&
|
||||
typeof (error as { kind?: unknown }).kind === 'string'
|
||||
) {
|
||||
return error as StalkerPortalError;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ describe('StalkerPortalRepairService', () => {
|
||||
/** When set, the atomic write fails AFTER the transform verified. */
|
||||
let persistError: Error | null;
|
||||
let setCachedToken: jest.Mock;
|
||||
let adoptDiscoveredSession: jest.Mock;
|
||||
let clearCachedToken: jest.Mock;
|
||||
let refreshActiveWatchdogPlaylist: jest.Mock;
|
||||
|
||||
@@ -61,6 +62,7 @@ describe('StalkerPortalRepairService', () => {
|
||||
return of(next);
|
||||
});
|
||||
setCachedToken = jest.fn();
|
||||
adoptDiscoveredSession = jest.fn();
|
||||
clearCachedToken = jest.fn();
|
||||
refreshActiveWatchdogPlaylist = jest.fn();
|
||||
|
||||
@@ -81,6 +83,7 @@ describe('StalkerPortalRepairService', () => {
|
||||
provide: StalkerSessionService,
|
||||
useValue: {
|
||||
setCachedToken,
|
||||
adoptDiscoveredSession,
|
||||
clearCachedToken,
|
||||
refreshActiveWatchdogPlaylist,
|
||||
},
|
||||
@@ -210,12 +213,13 @@ describe('StalkerPortalRepairService', () => {
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
// The classification handshake already produced a token,
|
||||
// tagged with the playlist as its identity source.
|
||||
expect(setCachedToken).toHaveBeenCalledWith(
|
||||
// The classification handshake already produced a session; it is
|
||||
// adopted whole (token + cadence), tagged with the REPAIRED
|
||||
// configuration as its identity source.
|
||||
expect(adoptDiscoveredSession).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
'TOKEN1',
|
||||
expect.objectContaining({ _id: 'portal-1' })
|
||||
expect.objectContaining({ _id: 'portal-1' }),
|
||||
expect.objectContaining({ token: 'TOKEN1' })
|
||||
);
|
||||
// A repaired ACTIVE playlist must re-sync the watchdog now: a
|
||||
// simple→full flip has to start the keepalive mid-session.
|
||||
@@ -282,6 +286,82 @@ describe('StalkerPortalRepairService', () => {
|
||||
expect(writtenRow).toBeNull();
|
||||
});
|
||||
|
||||
it('discards a repair whose credentials changed while probing', async () => {
|
||||
// Discovery can run for tens of seconds; a login saved meanwhile
|
||||
// means the outcome was negotiated for an account the row no
|
||||
// longer belongs to, so committing it would adopt the wrong
|
||||
// session.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://panel.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'WRONG-ACCOUNT',
|
||||
});
|
||||
// The row the transform sees carries the NEW login.
|
||||
persistedRow = {
|
||||
...MISCLASSIFIED,
|
||||
username: 'edited-mid-probe',
|
||||
} as Playlist;
|
||||
|
||||
expect(
|
||||
await service.repairPortal({
|
||||
...MISCLASSIFIED,
|
||||
username: 'original',
|
||||
})
|
||||
).toBeNull();
|
||||
expect(writtenRow).toBeNull();
|
||||
expect(adoptDiscoveredSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adopts the cadence the repair confirmation discovered', async () => {
|
||||
// Caching the token alone satisfies the retry, so NO
|
||||
// authentication path would ever apply the profile outcome — the
|
||||
// repaired playlist would keep the default cadence until the
|
||||
// token failed or the app restarted.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://panel.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'REPAIRED',
|
||||
watchdogTimeoutSeconds: 60,
|
||||
timeslotSeconds: 9,
|
||||
});
|
||||
|
||||
await service.repairPortal(MISCLASSIFIED);
|
||||
|
||||
expect(adoptDiscoveredSession).toHaveBeenCalledWith(
|
||||
MISCLASSIFIED._id,
|
||||
expect.objectContaining({
|
||||
portalUrl: 'http://panel.example/server/load.php',
|
||||
}),
|
||||
{
|
||||
token: 'REPAIRED',
|
||||
watchdogTimeoutSeconds: 60,
|
||||
timeslotSeconds: 9,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the playlist's stored credentials to discovery", async () => {
|
||||
// A login/password portal answers status 2 during confirmation;
|
||||
// without the credentials the probe reports `login-required` and
|
||||
// the source could never be repaired.
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
|
||||
await service.repairPortal({
|
||||
...MISCLASSIFIED,
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
expect(discover).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
{ credentials: { username: 'user', password: 'secret' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('probes at most once per playlist per session', async () => {
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
|
||||
@@ -438,7 +518,7 @@ describe('StalkerPortalRepairService', () => {
|
||||
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(writtenRow).toBeNull();
|
||||
expect(setCachedToken).not.toHaveBeenCalled();
|
||||
expect(adoptDiscoveredSession).not.toHaveBeenCalled();
|
||||
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -322,6 +322,11 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
playlist.portalUrl ?? '',
|
||||
isFullStalkerPortalPlaylist(playlist),
|
||||
stalkerIdentityFingerprint(playlist),
|
||||
// Credentials are part of the discovery outcome now: a probe that
|
||||
// failed on a wrong login must be retried once the login is
|
||||
// corrected, instead of staying declined until the app restarts.
|
||||
playlist.username ?? '',
|
||||
playlist.password ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -336,7 +341,18 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
const outcome = await this.discovery.discover(
|
||||
playlist.portalUrl ?? '',
|
||||
playlist.macAddress ?? '',
|
||||
getStalkerPortalIdentityFromPlaylist(playlist)
|
||||
getStalkerPortalIdentityFromPlaylist(playlist),
|
||||
{
|
||||
// A login/password portal answers `get_profile` with status 2
|
||||
// during confirmation. Without the stored credentials the
|
||||
// probe reports `login-required` and such a source could
|
||||
// never be repaired, even though the playlist holds a
|
||||
// working login.
|
||||
credentials: {
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (outcome.status !== 'resolved') {
|
||||
@@ -416,14 +432,22 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
?.set(this.repairSourceFingerprint(playlist), override);
|
||||
|
||||
if (outcome.isFullStalkerPortal && outcome.token) {
|
||||
// The classification handshake already authenticated; reuse its
|
||||
// token so the retry does not immediately handshake again. The
|
||||
// playlist itself is the identity source — a repair never
|
||||
// changes WHO the session belongs to, only WHERE it talks.
|
||||
this.stalkerSession.setCachedToken(
|
||||
// The classification handshake already authenticated; adopt the
|
||||
// whole session so the retry does not handshake again AND the
|
||||
// cadence that profile advertised is applied — caching the token
|
||||
// alone would satisfy the retry and leave the repaired playlist
|
||||
// pinging on the default until restart. The identity source is
|
||||
// the REPAIRED configuration: a repair never changes WHO the
|
||||
// session belongs to, only WHERE it talks, and the session is
|
||||
// bound to that endpoint.
|
||||
this.stalkerSession.adoptDiscoveredSession(
|
||||
playlist._id,
|
||||
outcome.token,
|
||||
playlist
|
||||
toStalkerSessionPlaylist(this.applyOverride(playlist)),
|
||||
{
|
||||
token: outcome.token,
|
||||
watchdogTimeoutSeconds: outcome.watchdogTimeoutSeconds,
|
||||
timeslotSeconds: outcome.timeslotSeconds,
|
||||
}
|
||||
);
|
||||
} else if (!outcome.isFullStalkerPortal) {
|
||||
this.stalkerSession.clearCachedToken(playlist._id);
|
||||
@@ -483,7 +507,13 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
row.portalUrl === playlist.portalUrl &&
|
||||
isFullStalkerPortalPlaylist(row) === sourceMode &&
|
||||
stalkerIdentityFingerprint(row) ===
|
||||
stalkerIdentityFingerprint(playlist)
|
||||
stalkerIdentityFingerprint(playlist) &&
|
||||
// Credentials too, matching repairSourceFingerprint(): discovery
|
||||
// can run for tens of seconds, and a login saved meanwhile means
|
||||
// the outcome was negotiated for an account the row no longer
|
||||
// belongs to — committing it would adopt the wrong session.
|
||||
(row.username ?? '') === (playlist.username ?? '') &&
|
||||
(row.password ?? '') === (playlist.password ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
isStalkerAuthFailureMessage,
|
||||
isStalkerAuthFailureResponse,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Checks whether a response or thrown error indicates a Stalker authorization
|
||||
* failure.
|
||||
*
|
||||
* Every signal here is structural. `isStalkerAuthFailureResponse` covers the
|
||||
* complete set of portal auth failures — the plain-text bodies, the transport
|
||||
* marker Electron mints from them, and the JSON-envelope forms — and is
|
||||
* shared with endpoint discovery and the lazy repair, so a phrase one layer
|
||||
* classifies as an auth failure cannot be ignored by another: the session
|
||||
* would otherwise keep an expired token and every later request fails.
|
||||
*
|
||||
* What is deliberately gone: the old `JSON.stringify(response)` regex sweep.
|
||||
* It matched the phrase anywhere in an arbitrary payload — including inside
|
||||
* legitimate catalog data — and existed only because the raw body reached the
|
||||
* renderer unclassified. The transports classify now, so the shapes are known.
|
||||
*/
|
||||
export function isStalkerAuthorizationFailure(
|
||||
responseOrError: unknown
|
||||
): boolean {
|
||||
if (!responseOrError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = responseOrError as Record<string, unknown>;
|
||||
const message = response?.['message'];
|
||||
|
||||
if (
|
||||
isStalkerAuthFailureResponse(responseOrError) ||
|
||||
isStalkerAuthFailureMessage(message)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTTP auth codes survive the IPC boundary only as message text
|
||||
// (`HTTP Error 401: …`): the custom `status` property is stripped by
|
||||
// ipcRenderer, so the numeric code must be read from the message.
|
||||
if (typeof message === 'string' && /HTTP Error 40[13]\b/.test(message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Same code on a response object that never crossed IPC.
|
||||
return response?.['status'] === 401 || response?.['status'] === 403;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import type { PlaylistsService } from '@iptvnator/services';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller';
|
||||
import { stalkerIdentityFingerprint } from './stalker-identity.utils';
|
||||
import type { StalkerAuthenticationResult } from './stalker-auth.api';
|
||||
|
||||
/**
|
||||
* Everything a Stalker session is bound to: the endpoint it was negotiated
|
||||
* against, the device identity, and the account credentials.
|
||||
*
|
||||
* All three halves matter, and each was a real defect when missing:
|
||||
*
|
||||
* - **Endpoint** — `ensureToken()` re-presents tokens in a handshake, so a
|
||||
* playlist repointed at another portal would disclose the previous one's
|
||||
* bearer token to it. Origin alone is not enough: discovery deliberately
|
||||
* preserves tenant base paths (`/tenant-a/…` vs `/tenant-b/…`), which are
|
||||
* different portals on one host.
|
||||
* - **Identity** — an edited MAC/serial must not inherit the old session.
|
||||
* - **Credentials** — for a status-2 portal the login decides which account
|
||||
* the token represents, so changing it must not keep serving the previous
|
||||
* account's session.
|
||||
*
|
||||
* Used for BOTH the in-run cache and the persisted session: an edit applies
|
||||
* without waiting for a restart.
|
||||
*/
|
||||
export function stalkerSessionFingerprint(
|
||||
playlist: Pick<
|
||||
Playlist,
|
||||
| 'portalUrl'
|
||||
| 'macAddress'
|
||||
| 'username'
|
||||
| 'password'
|
||||
| 'stalkerSerialNumber'
|
||||
| 'stalkerDeviceId1'
|
||||
| 'stalkerDeviceId2'
|
||||
| 'stalkerSignature1'
|
||||
| 'stalkerSignature2'
|
||||
>
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
portalEndpoint(playlist.portalUrl),
|
||||
stalkerIdentityFingerprint(playlist as Playlist),
|
||||
playlist.username ?? '',
|
||||
playlist.password ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin AND path, minus query/fragment — the endpoint a session belongs to.
|
||||
*
|
||||
* No attempt is made to treat different handler paths for the same portal
|
||||
* (`…/portal.php` vs `…/server/load.php`) as equivalent. Both sides of the
|
||||
* comparison read the SAME persisted `portalUrl`, so the same portal always
|
||||
* yields the same key; the value changes only on a real edit or repair, and
|
||||
* both of those re-authenticate anyway. An equivalence rule would add
|
||||
* machinery whose only failure mode is aliasing two genuinely different
|
||||
* endpoints — the exact thing this key exists to prevent.
|
||||
*/
|
||||
function portalEndpoint(portalUrl: string | undefined): string {
|
||||
if (!portalUrl) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(portalUrl);
|
||||
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
|
||||
} catch {
|
||||
// Unparseable: fall back to the raw string so a change is still a
|
||||
// change — never to a constant, which would alias every endpoint.
|
||||
return portalUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session facts persisted with the playlist between app starts.
|
||||
*
|
||||
* `identityFingerprint` is what makes the token safe to re-present: it records
|
||||
* the identity the session was negotiated for, so an edited MAC or serial
|
||||
* cannot inherit the previous session.
|
||||
*/
|
||||
export interface PersistedStalkerSession {
|
||||
token?: string;
|
||||
identityFingerprint?: string;
|
||||
watchdogTimeoutSeconds?: number;
|
||||
timeslotSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and writes the Stalker session persisted on the playlist row.
|
||||
*
|
||||
* Kept out of the session service because it is the only part that needs the
|
||||
* persistence stack, which is resolved lazily: the service is constructed
|
||||
* during bootstrap, and pulling `PlaylistsService` in eagerly would both
|
||||
* widen its dependency graph and risk a DI cycle.
|
||||
*/
|
||||
export class StalkerSessionStore {
|
||||
constructor(
|
||||
private readonly resolvePlaylistsService: () => PlaylistsService,
|
||||
private readonly logger: ReturnType<typeof createLogger>
|
||||
) {}
|
||||
|
||||
/** Reads a playlist row through the lazily-resolved persistence stack. */
|
||||
async readRow(playlistId: string): Promise<Playlist | undefined> {
|
||||
const row = await firstValueFrom(
|
||||
this.resolvePlaylistsService().getPlaylistById(playlistId)
|
||||
);
|
||||
return (row as Playlist) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the session persisted with the playlist: the token, the identity
|
||||
* it was negotiated for, and the watchdog cadence the portal advertised.
|
||||
*
|
||||
* The token is only offered for reuse when its identity still matches —
|
||||
* re-presenting one minted for an edited MAC would pair a new identity
|
||||
* with an old session, exactly what the in-memory cache guards against.
|
||||
*/
|
||||
async read(
|
||||
playlist: Playlist,
|
||||
fingerprint: string
|
||||
): Promise<PersistedStalkerSession> {
|
||||
const fromPlaylist: PersistedStalkerSession = {
|
||||
token: playlist.stalkerToken || undefined,
|
||||
identityFingerprint: playlist.stalkerSessionIdentity,
|
||||
watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout,
|
||||
timeslotSeconds: playlist.stalkerTimeslot,
|
||||
};
|
||||
|
||||
// Shortcut only when the object carries the cadence too. Taking it on
|
||||
// the token alone would silently drop a persisted cadence for any
|
||||
// playlist shape that copies the token without the timing fields.
|
||||
const stored =
|
||||
fromPlaylist.token &&
|
||||
fromPlaylist.watchdogTimeoutSeconds !== undefined
|
||||
? fromPlaylist
|
||||
: await this.readFromRow(playlist, fromPlaylist);
|
||||
|
||||
// A token with NO recorded fingerprint is unverified, not trusted:
|
||||
// playlists written before the fingerprint existed carry one, and
|
||||
// re-presenting it after an endpoint or identity edit is exactly the
|
||||
// disclosure the fingerprint prevents. Such a row has no cadence
|
||||
// either, so it already owes a full profile — refusing the token
|
||||
// costs it nothing, and the write-back then records the fingerprint.
|
||||
if (stored.token && stored.identityFingerprint !== fingerprint) {
|
||||
return { ...stored, token: undefined };
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
private async readFromRow(
|
||||
playlist: Playlist,
|
||||
fallback: PersistedStalkerSession
|
||||
): Promise<PersistedStalkerSession> {
|
||||
try {
|
||||
const stored = await this.readRow(playlist._id);
|
||||
return {
|
||||
// The row is authoritative, but a meta that carried a token
|
||||
// the row has not seen yet must not lose it.
|
||||
token: stored?.stalkerToken || fallback.token,
|
||||
identityFingerprint:
|
||||
stored?.stalkerSessionIdentity ??
|
||||
fallback.identityFingerprint,
|
||||
watchdogTimeoutSeconds: stored?.stalkerWatchdogTimeout,
|
||||
timeslotSeconds: stored?.stalkerTimeslot,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.debug('Persisted session lookup failed:', error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagates a successful authentication into session-side state: the
|
||||
* watchdog cadence, and the write-back.
|
||||
*
|
||||
* Reusing a stored token skips the `get_profile` that carries the
|
||||
* cadence, so the persisted cadence is applied instead — otherwise a
|
||||
* portal advertising a non-default `watchdog_timeout` would sit on the
|
||||
* 120 s fallback for the whole session.
|
||||
*/
|
||||
applyAuthenticationOutcome(
|
||||
playlistId: string,
|
||||
result: StalkerAuthenticationResult,
|
||||
stored: PersistedStalkerSession,
|
||||
fingerprint: string,
|
||||
watchdog: {
|
||||
applyProfileTiming: (
|
||||
playlistId: string,
|
||||
timing: {
|
||||
watchdogTimeoutSeconds?: number;
|
||||
timeslotSeconds?: number;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
): void {
|
||||
if (result.reusedStoredToken) {
|
||||
watchdog.applyProfileTiming(playlistId, {
|
||||
watchdogTimeoutSeconds: stored.watchdogTimeoutSeconds,
|
||||
timeslotSeconds: stored.timeslotSeconds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
watchdog.applyProfileTiming(playlistId, {
|
||||
watchdogTimeoutSeconds: result.watchdogTimeoutSeconds,
|
||||
timeslotSeconds: result.timeslotSeconds,
|
||||
});
|
||||
this.write(playlistId, result, stored, fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a renegotiated session back, best effort.
|
||||
*
|
||||
* The EFFECTIVE cadence is stored, not the raw one: a portal that
|
||||
* advertises nothing must still leave a value behind, or "no cadence
|
||||
* stored" would keep meaning "never profiled" and every start would
|
||||
* re-profile it. Stored absence therefore means exactly one thing — this
|
||||
* playlist has never completed a profile.
|
||||
*
|
||||
* Returns the cadence that was applied, so the caller can drive the
|
||||
* watchdog with the same numbers.
|
||||
*/
|
||||
write(
|
||||
playlistId: string,
|
||||
result: StalkerAuthenticationResult,
|
||||
stored: PersistedStalkerSession,
|
||||
fingerprint: string
|
||||
): void {
|
||||
const watchdogTimeout =
|
||||
result.watchdogTimeoutSeconds ??
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS;
|
||||
const timeslot = result.timeslotSeconds ?? 0;
|
||||
const changed =
|
||||
result.token !== stored.token ||
|
||||
fingerprint !== stored.identityFingerprint ||
|
||||
watchdogTimeout !== stored.watchdogTimeoutSeconds ||
|
||||
timeslot !== stored.timeslotSeconds;
|
||||
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void firstValueFrom(
|
||||
this.resolvePlaylistsService().updateStalkerSession(
|
||||
playlistId,
|
||||
{
|
||||
stalkerToken: result.token,
|
||||
stalkerSessionIdentity: fingerprint,
|
||||
stalkerWatchdogTimeout: watchdogTimeout,
|
||||
stalkerTimeslot: timeslot,
|
||||
}
|
||||
)
|
||||
).catch((error) => {
|
||||
this.logger.debug('Session write-back failed:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
// Persistence stack unavailable (e.g. isolated tests, or a DI
|
||||
// cycle at this point in bootstrap). The session still works for
|
||||
// this run; only the cross-restart reuse is lost.
|
||||
this.logger.debug('Session write-back unavailable:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalError } from './stalker-portal-error';
|
||||
import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller';
|
||||
import { stalkerSessionFingerprint } from './stalker-session-store';
|
||||
import {
|
||||
STALKER_SERIAL_NUMBER,
|
||||
StalkerProfileResponse,
|
||||
@@ -186,8 +189,10 @@ describe('StalkerSessionService identity-tagged token cache', () => {
|
||||
);
|
||||
|
||||
const oldAuth = service.ensureToken(playlistA);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await Promise.resolve();
|
||||
// ensureToken reads the persisted session before the handshake, so
|
||||
// wait for the transport call rather than a fixed microtask count.
|
||||
while (pendingResolvers.length === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
}
|
||||
|
||||
const editedIdentity = {
|
||||
@@ -198,10 +203,10 @@ describe('StalkerSessionService identity-tagged token cache', () => {
|
||||
|
||||
// Settle the OLD identity's handshake + profile.
|
||||
pendingResolvers[0]({ js: { token: 'TOKEN-OLD', random: 'r' } });
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
while (pendingResolvers.length < 2) {
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
}
|
||||
pendingResolvers[1]?.({ js: {} });
|
||||
pendingResolvers[1]({ js: {} });
|
||||
await expect(oldAuth).resolves.toMatchObject({ token: 'TOKEN-OLD' });
|
||||
|
||||
// The edited identity re-enters and negotiates its OWN session.
|
||||
@@ -285,13 +290,19 @@ describe('StalkerSessionService identity-tagged token cache', () => {
|
||||
service.setCachedToken('portal-1', 'DEAD', playlistA);
|
||||
sendIpcEvent.mockResolvedValue('Authorization failed.');
|
||||
|
||||
// The typed refusal replaced the generic "Authorization failed after
|
||||
// retry": callers need the portal's own reason to show the user.
|
||||
await expect(
|
||||
service.makeAuthenticatedRequest(
|
||||
playlistA,
|
||||
{ action: 'get_events' },
|
||||
false
|
||||
)
|
||||
).rejects.toThrow('Authorization failed after retry');
|
||||
).rejects.toMatchObject({
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'auth-failed',
|
||||
failureBody: 'Authorization failed.',
|
||||
});
|
||||
|
||||
// Leaving the dead token cached would hand it to the next caller.
|
||||
expect(service.getCachedToken('portal-1')).toBeNull();
|
||||
@@ -374,6 +385,10 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
|
||||
let service: StalkerSessionService;
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
let playlistsService: {
|
||||
getPlaylistById: jest.Mock;
|
||||
updateStalkerSession: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
@@ -390,11 +405,16 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }),
|
||||
};
|
||||
playlistsService = {
|
||||
getPlaylistById: jest.fn().mockReturnValue(of(undefined)),
|
||||
updateStalkerSession: jest.fn().mockReturnValue(of(null)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerSessionService,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{ provide: PlaylistsService, useValue: playlistsService },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -484,13 +504,19 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
stalkerSignature2: 'SIGNATURE-2',
|
||||
} as Playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {
|
||||
serialNumber: 'CUSTOMSN123',
|
||||
deviceId1: 'DEVICE-ID-1',
|
||||
deviceId2: 'DEVICE-ID-2',
|
||||
signature1: 'SIGNATURE-1',
|
||||
signature2: 'SIGNATURE-2',
|
||||
});
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{
|
||||
serialNumber: 'CUSTOMSN123',
|
||||
deviceId1: 'DEVICE-ID-1',
|
||||
deviceId2: 'DEVICE-ID-2',
|
||||
signature1: 'SIGNATURE-1',
|
||||
signature2: 'SIGNATURE-2',
|
||||
},
|
||||
// No cadence stored for this playlist, so the profile must run.
|
||||
expect.objectContaining({ skipProfileWhenReused: false })
|
||||
);
|
||||
});
|
||||
|
||||
it('treats the legacy default serial number as absent during ensureToken', async () => {
|
||||
@@ -507,7 +533,12 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
} as Playlist);
|
||||
|
||||
expect(result.serialNumber).toBeUndefined();
|
||||
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {});
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ skipProfileWhenReused: false })
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes refreshAccountProfile behind an in-flight ensureToken', async () => {
|
||||
@@ -536,8 +567,9 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
const refresh = service.refreshAccountProfile(playlist);
|
||||
|
||||
// Two handshakes must never overlap: on strict portals the second
|
||||
// would invalidate the first one's token.
|
||||
await Promise.resolve();
|
||||
// would invalidate the first one's token. (Macrotask flush: the
|
||||
// persisted-token lookup adds awaits before authenticate fires.)
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseFirst({ token: 'session-token' });
|
||||
@@ -574,7 +606,7 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
const first = service.refreshAccountProfile(playlist);
|
||||
const second = service.refreshAccountProfile(playlist);
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
|
||||
releases[0]({ token: 'session-token' });
|
||||
@@ -708,6 +740,581 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
expect(accountInfo).toEqual({ login: 'user-2' });
|
||||
});
|
||||
|
||||
it('re-presents the token persisted on the playlist during ensureToken', async () => {
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'STORED-TOKEN' });
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-7',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
stalkerToken: 'STORED-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint({
|
||||
portalUrl,
|
||||
macAddress,
|
||||
} as Playlist),
|
||||
// With the cadence present the playlist is self-sufficient, so no
|
||||
// row read is needed.
|
||||
stalkerWatchdogTimeout: 120,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.getPlaylistById).not.toHaveBeenCalled();
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({
|
||||
storedToken: 'STORED-TOKEN',
|
||||
skipProfileWhenReused: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the stored playlist row for the persisted token', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-8',
|
||||
stalkerToken: 'ROW-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint({
|
||||
portalUrl,
|
||||
macAddress,
|
||||
} as Playlist),
|
||||
stalkerWatchdogTimeout: 120,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'ROW-TOKEN', reusedStoredToken: true });
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-8',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
|
||||
'playlist-8'
|
||||
);
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ storedToken: 'ROW-TOKEN' })
|
||||
);
|
||||
// A reused token was not renegotiated — nothing to write back.
|
||||
expect(playlistsService.updateStalkerSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes a newly negotiated token back to the playlist', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({ _id: 'playlist-9', stalkerToken: 'OLD-TOKEN' } as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'NEW-TOKEN',
|
||||
reusedStoredToken: false,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-9',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
|
||||
'playlist-9',
|
||||
expect.objectContaining({ stalkerToken: 'NEW-TOKEN' })
|
||||
);
|
||||
});
|
||||
|
||||
it('applies the persisted watchdog cadence when the token is reused', async () => {
|
||||
// Reuse skips the get_profile that carries the cadence, so without
|
||||
// the persisted values the watchdog would sit on its 120 s default
|
||||
// for the whole session — for a portal that advertised 60 s that is
|
||||
// slower than before this feature existed.
|
||||
const watchdog = (
|
||||
service as unknown as {
|
||||
watchdog: { applyProfileTiming: jest.Mock };
|
||||
}
|
||||
).watchdog;
|
||||
jest.spyOn(watchdog, 'applyProfileTiming');
|
||||
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-12',
|
||||
stalkerToken: 'REUSED',
|
||||
stalkerWatchdogTimeout: 60,
|
||||
stalkerTimeslot: 7,
|
||||
} as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'REUSED',
|
||||
reusedStoredToken: true,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-12',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(watchdog.applyProfileTiming).toHaveBeenCalledWith(
|
||||
'playlist-12',
|
||||
{ watchdogTimeoutSeconds: 60, timeslotSeconds: 7 }
|
||||
);
|
||||
});
|
||||
|
||||
it('profiles a legacy token-only playlist and refuses its unverified token', async () => {
|
||||
// A playlist written before this change has a token but no recorded
|
||||
// fingerprint, so nothing proves which endpoint/identity it belongs
|
||||
// to — re-presenting it after an edit is the disclosure the
|
||||
// fingerprint exists to prevent. It owes a full profile anyway (no
|
||||
// cadence), so refusing the token costs nothing and the write-back
|
||||
// then records the fingerprint.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({ _id: 'playlist-16', stalkerToken: 'LEGACY' } as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({
|
||||
token: 'LEGACY',
|
||||
reusedStoredToken: false,
|
||||
watchdogTimeoutSeconds: 60,
|
||||
timeslotSeconds: 5,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-16',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({
|
||||
storedToken: undefined,
|
||||
skipProfileWhenReused: false,
|
||||
})
|
||||
);
|
||||
// ...and the learned cadence is persisted, so the next start skips.
|
||||
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
|
||||
'playlist-16',
|
||||
expect.objectContaining({
|
||||
stalkerWatchdogTimeout: 60,
|
||||
stalkerTimeslot: 5,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('records the effective cadence when the portal advertises none', async () => {
|
||||
// Otherwise "no cadence stored" would keep meaning "never profiled"
|
||||
// and such a portal would be re-profiled on every single start.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({ _id: 'playlist-17', stalkerToken: 'LEGACY' } as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'LEGACY',
|
||||
reusedStoredToken: false,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-17',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
|
||||
'playlist-17',
|
||||
expect.objectContaining({
|
||||
stalkerWatchdogTimeout:
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
|
||||
stalkerTimeslot: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a cached and persisted session after the login changed', async () => {
|
||||
// For a status-2 portal the login decides WHICH account the token
|
||||
// represents, so serving the old session would keep the user on the
|
||||
// previous account indefinitely.
|
||||
const before = {
|
||||
_id: 'playlist-login-change',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
username: 'old-user',
|
||||
password: 'old-pass',
|
||||
} as Playlist;
|
||||
service.setCachedToken(before._id, 'OLD-ACCOUNT-TOKEN', before);
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
...before,
|
||||
stalkerToken: 'OLD-ACCOUNT-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint(before),
|
||||
stalkerWatchdogTimeout: 60,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'NEW', reusedStoredToken: false });
|
||||
|
||||
const result = await service.ensureToken({
|
||||
...before,
|
||||
username: 'new-user',
|
||||
password: 'new-pass',
|
||||
} as Playlist);
|
||||
|
||||
// Neither the in-run cache nor the persisted token is reused.
|
||||
expect(result.token).toBe('NEW');
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({
|
||||
storedToken: undefined,
|
||||
credentials: { username: 'new-user', password: 'new-pass' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a persisted token across tenants on the SAME host', async () => {
|
||||
// Discovery deliberately preserves tenant base paths, so two portals
|
||||
// can share an origin. An origin-only key would send tenant A's
|
||||
// bearer to tenant B.
|
||||
const tenantA = {
|
||||
_id: 'playlist-tenant',
|
||||
portalUrl: 'https://panel.example.com/tenant-a/server/load.php',
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
...tenantA,
|
||||
stalkerToken: 'TENANT-A-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint(tenantA),
|
||||
stalkerWatchdogTimeout: 60,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'FRESH', reusedStoredToken: false });
|
||||
|
||||
await service.ensureToken({
|
||||
...tenantA,
|
||||
portalUrl: 'https://panel.example.com/tenant-b/server/load.php',
|
||||
} as Playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
'https://panel.example.com/tenant-b/server/load.php',
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ storedToken: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the session for the same endpoint spelled with a trailing slash', async () => {
|
||||
// Normalisation must not turn a cosmetic difference into a forced
|
||||
// re-authentication.
|
||||
const portal = {
|
||||
_id: 'playlist-slash',
|
||||
portalUrl: 'https://panel.example.com/tenant-a/server/load.php',
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
expect(
|
||||
stalkerSessionFingerprint({
|
||||
...portal,
|
||||
portalUrl: 'https://panel.example.com/tenant-a/server/load.php/',
|
||||
} as Playlist)
|
||||
).toBe(stalkerSessionFingerprint(portal));
|
||||
});
|
||||
|
||||
it('refuses a persisted token when the playlist was repointed at another host', async () => {
|
||||
// ensureToken re-presents persisted tokens in a handshake, so an
|
||||
// identity-only check would disclose the previous portal's bearer
|
||||
// token to an unrelated server.
|
||||
const original = {
|
||||
_id: 'playlist-moved',
|
||||
portalUrl: 'https://old.example.com/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
...original,
|
||||
stalkerToken: 'OLD-HOST-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint(original),
|
||||
stalkerWatchdogTimeout: 60,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'FRESH', reusedStoredToken: false });
|
||||
|
||||
await service.ensureToken({
|
||||
...original,
|
||||
portalUrl: 'https://new.example.com/stalker_portal/server/load.php',
|
||||
} as Playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
'https://new.example.com/stalker_portal/server/load.php',
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ storedToken: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a persisted token minted for a different identity', async () => {
|
||||
// The playlist was edited after the token was issued. Re-presenting
|
||||
// it would pair the new identity with the old session — the same bug
|
||||
// class the in-memory cache guards against, just across a restart.
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-identity',
|
||||
stalkerToken: 'OLD-IDENTITY-TOKEN',
|
||||
stalkerSessionIdentity: 'not-the-current-fingerprint',
|
||||
stalkerWatchdogTimeout: 60,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({ token: 'FRESH', reusedStoredToken: false });
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-identity',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ storedToken: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses a persisted token whose identity still matches', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-identity-ok',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
...playlist,
|
||||
stalkerToken: 'SAME-IDENTITY-TOKEN',
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint(playlist),
|
||||
stalkerWatchdogTimeout: 60,
|
||||
} as Playlist)
|
||||
);
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockResolvedValue({
|
||||
token: 'SAME-IDENTITY-TOKEN',
|
||||
reusedStoredToken: true,
|
||||
});
|
||||
|
||||
await service.ensureToken(playlist);
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
{},
|
||||
expect.objectContaining({ storedToken: 'SAME-IDENTITY-TOKEN' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the stored row when a token arrives without its cadence', async () => {
|
||||
// A playlist shape that copies the token but not the timing fields
|
||||
// must not silently downgrade the portal to the 120 s default.
|
||||
const watchdog = (
|
||||
service as unknown as {
|
||||
watchdog: { applyProfileTiming: jest.Mock };
|
||||
}
|
||||
).watchdog;
|
||||
jest.spyOn(watchdog, 'applyProfileTiming');
|
||||
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-15',
|
||||
stalkerToken: 'REUSED',
|
||||
stalkerWatchdogTimeout: 45,
|
||||
stalkerTimeslot: 3,
|
||||
} as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'REUSED',
|
||||
reusedStoredToken: true,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-15',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
// Token present, cadence absent — the shortcut must not fire.
|
||||
stalkerToken: 'REUSED',
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
|
||||
'playlist-15'
|
||||
);
|
||||
expect(watchdog.applyProfileTiming).toHaveBeenCalledWith(
|
||||
'playlist-15',
|
||||
{ watchdogTimeoutSeconds: 45, timeslotSeconds: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
it('persists a freshly decoded watchdog cadence with the token', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({ _id: 'playlist-13' } as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'NEW-TOKEN',
|
||||
reusedStoredToken: false,
|
||||
watchdogTimeoutSeconds: 90,
|
||||
timeslotSeconds: 12,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-13',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
|
||||
'playlist-13',
|
||||
expect.objectContaining({
|
||||
stalkerToken: 'NEW-TOKEN',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 12,
|
||||
// The identity the session was negotiated for, so a later
|
||||
// start can refuse to reuse it after an identity edit.
|
||||
stalkerSessionIdentity: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites the session when only the cadence changed', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'playlist-14',
|
||||
stalkerToken: 'SAME',
|
||||
stalkerWatchdogTimeout: 120,
|
||||
} as Playlist)
|
||||
);
|
||||
jest.spyOn(service, 'authenticate').mockResolvedValue({
|
||||
token: 'SAME',
|
||||
reusedStoredToken: false,
|
||||
watchdogTimeoutSeconds: 45,
|
||||
});
|
||||
|
||||
await service.ensureToken({
|
||||
_id: 'playlist-14',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
|
||||
'playlist-14',
|
||||
expect.objectContaining({ stalkerWatchdogTimeout: 45 })
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the portal failure body when the retry also fails', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-10',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
jest.spyOn(service, 'ensureToken').mockResolvedValue({
|
||||
token: 'token-1',
|
||||
});
|
||||
// The transport marker for a blocked account, on both attempts.
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
stalkerAuthFailure: 'Access denied.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.makeAuthenticatedRequest(playlist, {
|
||||
type: 'vod',
|
||||
action: 'get_categories',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'auth-failed',
|
||||
failureBody: 'Access denied.',
|
||||
});
|
||||
// One retry happened before giving up.
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('recognizes the raw PWA plain-text failure body and retries', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-11',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
jest.spyOn(service, 'ensureToken')
|
||||
.mockResolvedValueOnce({ token: 'stale' })
|
||||
.mockResolvedValueOnce({ token: 'fresh' });
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce('Unauthorized request.')
|
||||
.mockResolvedValueOnce({ js: { data: [] } });
|
||||
|
||||
await expect(
|
||||
service.makeAuthenticatedRequest(playlist, {
|
||||
type: 'itv',
|
||||
action: 'get_ordered_list',
|
||||
})
|
||||
).resolves.toEqual({ js: { data: [] } });
|
||||
});
|
||||
|
||||
it('throws StalkerPortalError with the portal text when auth is refused', async () => {
|
||||
// Blocked account: get_profile answers status 1 with msg/block_msg.
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'token-1', random: 'random-1' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
js: {
|
||||
status: 1,
|
||||
msg: 'device conflict - device_id mismatch',
|
||||
block_msg: 'Your STB is damaged.<br/> Call the provider.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'blocked',
|
||||
portalText:
|
||||
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps StalkerPortalError recognizable via instanceof', () => {
|
||||
expect(new StalkerPortalError('blocked')).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('passes an explicit serial into the initial handshake request', async () => {
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
||||
export interface StalkerPendingAuth {
|
||||
promise: Promise<{ token: string; serialNumber?: string }>;
|
||||
identityFingerprint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session state for the current app run, keyed by playlist ID and
|
||||
* tagged with the identity fingerprint the session was negotiated for.
|
||||
*
|
||||
* The tagging is the point: a playlist whose endpoint, identity or login was
|
||||
* edited must never inherit the previous session — neither the cached token
|
||||
* nor an authentication still in flight for the old one. The key comes from
|
||||
* `stalkerSessionFingerprint`, so an edit applies without a restart.
|
||||
*/
|
||||
export class StalkerTokenCache {
|
||||
private readonly tokens = new Map<
|
||||
string,
|
||||
{ token: string; identityFingerprint: string }
|
||||
>();
|
||||
private readonly pending = new Map<string, StalkerPendingAuth>();
|
||||
|
||||
/**
|
||||
* The cached token regardless of identity. Playback fast paths that
|
||||
* cannot supply an identity use this; `takeFor` is the checked accessor.
|
||||
*/
|
||||
get(playlistId: string): string | null {
|
||||
return this.tokens.get(playlistId)?.token || null;
|
||||
}
|
||||
|
||||
set(playlistId: string, token: string, sessionFingerprint: string): void {
|
||||
this.tokens.set(playlistId, {
|
||||
token,
|
||||
identityFingerprint: sessionFingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
clear(playlistId: string): void {
|
||||
this.tokens.delete(playlistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached token when it was negotiated for this exact identity. A
|
||||
* mismatch retires the entry and returns null, so the caller
|
||||
* authenticates as the current identity instead of pairing a new one
|
||||
* with an old session.
|
||||
*/
|
||||
takeFor(playlistId: string, fingerprint: string): string | null {
|
||||
const cached = this.tokens.get(playlistId);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (cached.identityFingerprint === fingerprint) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
this.clear(playlistId);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached token only while it is still the one that just
|
||||
* failed. A request dispatched with the previous token can see its
|
||||
* authorization failure arrive after a profile refresh has already
|
||||
* cached a fresh token — deleting blindly would kill that fresh token
|
||||
* and trigger a cascade of competing handshakes on strict portals.
|
||||
*/
|
||||
retireFailed(playlistId: string, failedToken: string | null): void {
|
||||
if (failedToken && this.get(playlistId) === failedToken) {
|
||||
this.clear(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
getPending(playlistId: string): StalkerPendingAuth | undefined {
|
||||
return this.pending.get(playlistId);
|
||||
}
|
||||
|
||||
setPending(playlistId: string, entry: StalkerPendingAuth): void {
|
||||
this.pending.set(playlistId, entry);
|
||||
}
|
||||
|
||||
clearPending(playlistId: string): void {
|
||||
this.pending.delete(playlistId);
|
||||
}
|
||||
|
||||
/** Retires the pending slot only when it is still the caller's own. */
|
||||
clearPendingIf(playlistId: string, entry: StalkerPendingAuth): void {
|
||||
if (this.pending.get(playlistId) === entry) {
|
||||
this.pending.delete(playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import { VodDetailsItem } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerFavoriteItem } from './models';
|
||||
import {
|
||||
buildStalkerFavoritePayload,
|
||||
buildStalkerSelectedVodItem,
|
||||
createStalkerInfo,
|
||||
createStalkerInlineDetailState,
|
||||
createStalkerDetailViewState,
|
||||
isStalkerSeriesFlag,
|
||||
normalizeStalkerFavoriteItem,
|
||||
normalizeStalkerSeriesFlag,
|
||||
normalizeStalkerVodDetailsItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from './stalker-vod.utils';
|
||||
|
||||
@@ -286,4 +288,52 @@ describe('stalker-vod.utils regressions', () => {
|
||||
expect(info.tmdb_trailer).toBe('abc123def');
|
||||
expect(info.tmdb_recommendations).toEqual(tmdbRecommendations);
|
||||
});
|
||||
|
||||
describe('temporary-link flags survive normalization', () => {
|
||||
// `buildStalkerSelectedVodItem` is a whitelist, so a field it forgets
|
||||
// is silently gone. For these two that fails OPEN: playback and
|
||||
// downloads would read "no temporary link needed" and play the
|
||||
// portal's non-final URL instead of minting one.
|
||||
const FLAGGED_ROW = {
|
||||
id: '42',
|
||||
cmd: 'ffrt3 http://cdn.example/movie.mkv',
|
||||
use_http_tmp_link: '1',
|
||||
use_load_balancing: '0',
|
||||
info: { name: 'Flagged Movie' },
|
||||
};
|
||||
|
||||
it('keeps both flags on the selected VOD item', () => {
|
||||
const selected = buildStalkerSelectedVodItem(FLAGGED_ROW);
|
||||
|
||||
expect(selected.use_http_tmp_link).toBe('1');
|
||||
expect(selected.use_load_balancing).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps both flags through the VOD details normalizer', () => {
|
||||
const normalized = normalizeStalkerVodDetailsItem(FLAGGED_ROW);
|
||||
|
||||
expect(normalized.use_http_tmp_link).toBe('1');
|
||||
expect(normalized.use_load_balancing).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps both flags on a normalized favorite', () => {
|
||||
const normalized = normalizeStalkerFavoriteItem(
|
||||
FLAGGED_ROW as StalkerFavoriteItem
|
||||
);
|
||||
|
||||
expect(normalized.details.use_http_tmp_link).toBe('1');
|
||||
expect(normalized.details.use_load_balancing).toBe('0');
|
||||
});
|
||||
|
||||
it('leaves an unflagged row without inventing flags', () => {
|
||||
const selected = buildStalkerSelectedVodItem({
|
||||
id: '43',
|
||||
cmd: 'ffrt3 http://cdn.example/other.mkv',
|
||||
info: { name: 'Plain Movie' },
|
||||
});
|
||||
|
||||
expect(selected.use_http_tmp_link).toBeUndefined();
|
||||
expect(selected.use_load_balancing).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,16 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a raw portal row to the fields the detail/playback flows need.
|
||||
*
|
||||
* This is a whitelist, not a spread, so anything not named here is dropped.
|
||||
* `use_http_tmp_link` / `use_load_balancing` MUST stay on the list: playback
|
||||
* and downloads read them to decide whether `create_link` is required, and a
|
||||
* missing flag reads as "no temporary link needed" — it fails OPEN, playing
|
||||
* the portal's non-final URL instead of minting one. See
|
||||
* `docs/architecture/stalker-portal.md`, "Playback Link Resolution".
|
||||
*/
|
||||
export function buildStalkerSelectedVodItem(
|
||||
item: StalkerVodSource,
|
||||
forceSeries = false
|
||||
@@ -169,6 +179,8 @@ export function buildStalkerSelectedVodItem(
|
||||
cmd: toStringOrFallback(item.cmd),
|
||||
series: item.series,
|
||||
has_files: item.has_files,
|
||||
use_http_tmp_link: item.use_http_tmp_link,
|
||||
use_load_balancing: item.use_load_balancing,
|
||||
is_series:
|
||||
forceSeries || isStalkerSeriesFlag(item?.is_series)
|
||||
? true
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerWatchdogController } from './stalker-watchdog.controller';
|
||||
|
||||
describe('StalkerWatchdogController', () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-1',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
let sendRequest: jest.Mock;
|
||||
let readPersistedPlaylist: jest.Mock;
|
||||
let controller: StalkerWatchdogController;
|
||||
|
||||
const logger = {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as ReturnType<typeof createLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
sendRequest = jest.fn().mockResolvedValue({ js: {} });
|
||||
// The row is the source of truth for each ping; specs that care
|
||||
// about mid-session edits override this per test.
|
||||
readPersistedPlaylist = jest.fn().mockResolvedValue(playlist);
|
||||
controller = new StalkerWatchdogController({
|
||||
sendRequest,
|
||||
readPersistedPlaylist,
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.setActivePlaylist(null);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/** Lets the async sendPing settle between timer advances. */
|
||||
async function flush(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
it('pings immediately with init=1, then every 120 s by default', async () => {
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
playlist,
|
||||
expect.objectContaining({
|
||||
action: 'get_events',
|
||||
type: 'watchdog',
|
||||
init: '1',
|
||||
})
|
||||
);
|
||||
|
||||
// The legacy cadence was 25 s — nothing may fire that early.
|
||||
jest.advanceTimersByTime(25_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(95_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2);
|
||||
expect(sendRequest.mock.calls[1][1]).toEqual(
|
||||
expect.objectContaining({ init: '0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('reschedules to the profile cadence with the timeslot offset', async () => {
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
sendRequest.mockClear();
|
||||
|
||||
controller.applyProfileTiming(playlist._id, {
|
||||
watchdogTimeoutSeconds: 90,
|
||||
timeslotSeconds: 10,
|
||||
});
|
||||
|
||||
// Timeslot offset: nothing during the first 10 s...
|
||||
jest.advanceTimersByTime(9_000);
|
||||
await flush();
|
||||
expect(sendRequest).not.toHaveBeenCalled();
|
||||
|
||||
// ...then the 90 s cadence starts.
|
||||
jest.advanceTimersByTime(91_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(90_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the stored cadence when the watchdog restarts', async () => {
|
||||
controller.applyProfileTiming(playlist._id, {
|
||||
watchdogTimeoutSeconds: 60,
|
||||
timeslotSeconds: 0,
|
||||
});
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
sendRequest.mockClear();
|
||||
|
||||
jest.advanceTimersByTime(60_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clamps a garbage watchdog_timeout instead of adopting it', async () => {
|
||||
controller.applyProfileTiming(playlist._id, {
|
||||
watchdogTimeoutSeconds: 1,
|
||||
timeslotSeconds: 0,
|
||||
});
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
sendRequest.mockClear();
|
||||
|
||||
// Clamped to the 30 s floor — a 1 s cadence would hammer the portal.
|
||||
jest.advanceTimersByTime(29_000);
|
||||
await flush();
|
||||
expect(sendRequest).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(1_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs and continues when a ping fails — never retries or escalates', async () => {
|
||||
sendRequest.mockRejectedValue(new Error('portal down'));
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
// No immediate retry: the next attempt is the next scheduled tick.
|
||||
jest.advanceTimersByTime(119_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1);
|
||||
jest.advanceTimersByTime(1_000);
|
||||
await flush();
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops pinging when the active playlist is cleared', async () => {
|
||||
controller.setActivePlaylist(playlist);
|
||||
await flush();
|
||||
sendRequest.mockClear();
|
||||
|
||||
controller.setActivePlaylist(null);
|
||||
jest.advanceTimersByTime(600_000);
|
||||
await flush();
|
||||
expect(sendRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never pings for a simple (non-full) portal', async () => {
|
||||
controller.setActivePlaylist({
|
||||
...playlist,
|
||||
isFullStalkerPortal: false,
|
||||
} as Playlist);
|
||||
jest.advanceTimersByTime(600_000);
|
||||
await flush();
|
||||
expect(sendRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
isFullStalkerPortalPlaylist,
|
||||
type Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
|
||||
/**
|
||||
* The portal expects `get_events` every `watchdog_timeout` seconds — 120 by
|
||||
* default — not every 25 s as the client historically pinged.
|
||||
*/
|
||||
export const STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS = 120;
|
||||
|
||||
/** Guard rails for a garbage `watchdog_timeout` echoed by a broken panel. */
|
||||
const MIN_PERIOD_SECONDS = 30;
|
||||
const MAX_PERIOD_SECONDS = 3600;
|
||||
|
||||
export interface StalkerWatchdogTiming {
|
||||
periodSeconds: number;
|
||||
/** Per-user jitter: delays the first periodic ping so clients spread out. */
|
||||
timeslotSeconds: number;
|
||||
}
|
||||
|
||||
export interface StalkerWatchdogDeps {
|
||||
/** Sends one authenticated `get_events` ping. */
|
||||
sendRequest: (
|
||||
playlist: Playlist,
|
||||
params: Record<string, string | number>
|
||||
) => Promise<unknown>;
|
||||
/**
|
||||
* Reads the persisted row for a playlist. The row is the source of truth
|
||||
* for the configuration a ping authenticates as; see `resolvePlaylist`.
|
||||
*/
|
||||
readPersistedPlaylist: (playlistId: string) => Promise<Playlist | undefined>;
|
||||
logger: ReturnType<typeof createLogger>;
|
||||
}
|
||||
|
||||
interface WatchdogTimers {
|
||||
timeslotTimeout?: ReturnType<typeof setTimeout>;
|
||||
interval?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the active full portal's session "online" by pinging `get_events`.
|
||||
*
|
||||
* The cadence comes from the profile (`watchdog_timeout` + `timeslot`); until
|
||||
* a profile has been decoded the documented default of 120 s applies. A missed
|
||||
* ping never invalidates authentication — it only affects the portal's
|
||||
* "online" reporting — so failures are logged and never retried or escalated.
|
||||
*/
|
||||
export class StalkerWatchdogController {
|
||||
private readonly timers = new Map<string, WatchdogTimers>();
|
||||
/** Activation-time snapshot; only a fallback when the row cannot be read. */
|
||||
private readonly playlists = new Map<string, Playlist>();
|
||||
private readonly inFlight = new Set<string>();
|
||||
/** Survives stop/start: the cadence is a per-portal fact, not session state. */
|
||||
private readonly timings = new Map<string, StalkerWatchdogTiming>();
|
||||
private activePlaylistId: string | null = null;
|
||||
private playlistDecorator: ((playlist: Playlist) => Playlist) | null = null;
|
||||
|
||||
constructor(private readonly deps: StalkerWatchdogDeps) {}
|
||||
|
||||
/**
|
||||
* Sets which playlist should receive periodic watchdog pings.
|
||||
* This keeps some Ministra/Stalker sessions alive for live playback.
|
||||
*/
|
||||
setActivePlaylist(playlist?: Playlist | null): void {
|
||||
const nextPlaylistId = playlist?._id ?? null;
|
||||
|
||||
if (this.activePlaylistId && this.activePlaylistId !== nextPlaylistId) {
|
||||
this.stop(this.activePlaylistId);
|
||||
}
|
||||
|
||||
this.activePlaylistId = nextPlaylistId;
|
||||
|
||||
if (
|
||||
!playlist ||
|
||||
!isFullStalkerPortalPlaylist(playlist) ||
|
||||
!playlist.portalUrl ||
|
||||
!playlist.macAddress
|
||||
) {
|
||||
if (nextPlaylistId) {
|
||||
this.stop(nextPlaylistId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.start(playlist);
|
||||
}
|
||||
|
||||
/** Whether this playlist currently owns the watchdog. */
|
||||
isActivePlaylist(playlistId: string): boolean {
|
||||
return this.activePlaylistId === playlistId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the repair layer overlay its in-session override on the row a ping
|
||||
* resolves: the persisted row can still carry a pre-repair configuration
|
||||
* while persistence is pending (or failed), and pinging that would stop
|
||||
* or misdirect the keepalive.
|
||||
*/
|
||||
registerPlaylistDecorator(decorator: (playlist: Playlist) => Playlist): void {
|
||||
this.playlistDecorator = decorator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the cadence decoded from a `get_profile` response. When the
|
||||
* playlist's watchdog is already running on a different cadence, its
|
||||
* periodic schedule restarts (without re-sending the init ping).
|
||||
*/
|
||||
applyProfileTiming(
|
||||
playlistId: string,
|
||||
timing: { watchdogTimeoutSeconds?: number; timeslotSeconds?: number }
|
||||
): void {
|
||||
const next = normalizeTiming(timing);
|
||||
const current = this.timings.get(playlistId);
|
||||
if (
|
||||
current &&
|
||||
current.periodSeconds === next.periodSeconds &&
|
||||
current.timeslotSeconds === next.timeslotSeconds
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timings.set(playlistId, next);
|
||||
|
||||
if (this.timers.has(playlistId)) {
|
||||
this.clearTimers(playlistId);
|
||||
this.timers.set(playlistId, {});
|
||||
this.schedule(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
private start(playlist: Playlist): void {
|
||||
const playlistId = playlist._id;
|
||||
this.playlists.set(playlistId, playlist);
|
||||
|
||||
if (this.timers.has(playlistId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timers.set(playlistId, {});
|
||||
void this.sendPing(playlistId, '1');
|
||||
this.schedule(playlistId);
|
||||
}
|
||||
|
||||
private stop(playlistId: string): void {
|
||||
this.clearTimers(playlistId);
|
||||
this.timers.delete(playlistId);
|
||||
this.playlists.delete(playlistId);
|
||||
this.inFlight.delete(playlistId);
|
||||
}
|
||||
|
||||
private clearTimers(playlistId: string): void {
|
||||
const timers = this.timers.get(playlistId);
|
||||
if (!timers) {
|
||||
return;
|
||||
}
|
||||
if (timers.timeslotTimeout) {
|
||||
clearTimeout(timers.timeslotTimeout);
|
||||
}
|
||||
if (timers.interval) {
|
||||
clearInterval(timers.interval);
|
||||
}
|
||||
}
|
||||
|
||||
private timingFor(playlistId: string): StalkerWatchdogTiming {
|
||||
return (
|
||||
this.timings.get(playlistId) ?? {
|
||||
periodSeconds: STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
|
||||
timeslotSeconds: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private schedule(playlistId: string): void {
|
||||
const timers = this.timers.get(playlistId);
|
||||
if (!timers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { periodSeconds, timeslotSeconds } = this.timingFor(playlistId);
|
||||
const startInterval = () => {
|
||||
const current = this.timers.get(playlistId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.timeslotTimeout = undefined;
|
||||
current.interval = setInterval(() => {
|
||||
void this.sendPing(playlistId, '0');
|
||||
}, periodSeconds * 1000);
|
||||
};
|
||||
|
||||
if (timeslotSeconds > 0) {
|
||||
timers.timeslotTimeout = setTimeout(
|
||||
startInterval,
|
||||
timeslotSeconds * 1000
|
||||
);
|
||||
} else {
|
||||
startInterval();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The playlist a ping authenticates as. The persisted row is the source of
|
||||
* truth: portal metadata (endpoint, mode, MAC, identity) edited or
|
||||
* repaired mid-session must reach the keepalive within one cycle — the
|
||||
* activation-time snapshot is only the fallback when the row cannot be
|
||||
* read.
|
||||
*/
|
||||
private async resolvePlaylist(
|
||||
playlistId: string
|
||||
): Promise<Playlist | undefined> {
|
||||
try {
|
||||
const row = await this.deps.readPersistedPlaylist(playlistId);
|
||||
if (row) {
|
||||
// Keep the fallback snapshot fresh for the next cycle.
|
||||
this.playlists.set(playlistId, row);
|
||||
return this.decorate(row);
|
||||
}
|
||||
} catch {
|
||||
// Store unavailable (e.g. isolated tests): keep the snapshot.
|
||||
}
|
||||
|
||||
const snapshot = this.playlists.get(playlistId);
|
||||
return snapshot ? this.decorate(snapshot) : snapshot;
|
||||
}
|
||||
|
||||
private decorate(playlist: Playlist): Playlist {
|
||||
return this.playlistDecorator
|
||||
? this.playlistDecorator(playlist)
|
||||
: playlist;
|
||||
}
|
||||
|
||||
private async sendPing(playlistId: string, init: '0' | '1'): Promise<void> {
|
||||
if (this.inFlight.has(playlistId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Claimed BEFORE the row read: it awaits, and two overlapping pings
|
||||
// passing the check together would double-fire the keepalive.
|
||||
this.inFlight.add(playlistId);
|
||||
try {
|
||||
const playlist = await this.resolvePlaylist(playlistId);
|
||||
if (
|
||||
!playlist ||
|
||||
!playlist.portalUrl ||
|
||||
!playlist.macAddress ||
|
||||
!isFullStalkerPortalPlaylist(playlist)
|
||||
) {
|
||||
this.stop(playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deps.sendRequest(playlist, {
|
||||
type: 'watchdog',
|
||||
action: 'get_events',
|
||||
event_active_id: '0',
|
||||
cur_play_type: '0',
|
||||
init,
|
||||
JsHttpRequest: '1-xml',
|
||||
});
|
||||
} catch (error) {
|
||||
// Keep failures non-fatal; the next tick can recover after a
|
||||
// token refresh. Never retry or escalate: a missed ping does not
|
||||
// invalidate authentication.
|
||||
this.deps.logger.warn('Watchdog ping failed:', error);
|
||||
} finally {
|
||||
this.inFlight.delete(playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTiming(timing: {
|
||||
watchdogTimeoutSeconds?: number;
|
||||
timeslotSeconds?: number;
|
||||
}): StalkerWatchdogTiming {
|
||||
const period = clamp(
|
||||
Math.floor(
|
||||
timing.watchdogTimeoutSeconds ??
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS
|
||||
),
|
||||
MIN_PERIOD_SECONDS,
|
||||
MAX_PERIOD_SECONDS
|
||||
);
|
||||
const timeslot = clamp(Math.floor(timing.timeslotSeconds ?? 0), 0, period - 1);
|
||||
return { periodSeconds: period, timeslotSeconds: timeslot };
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
+193
-20
@@ -31,6 +31,20 @@ const PLAYLIST = {
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
interface SelectedTestItem {
|
||||
id: string;
|
||||
cmd: string;
|
||||
has_files?: boolean;
|
||||
title?: string;
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
logo?: string;
|
||||
category_id?: string;
|
||||
cover?: string;
|
||||
use_http_tmp_link?: unknown;
|
||||
use_load_balancing?: unknown;
|
||||
}
|
||||
|
||||
const TestPlayerStore = signalStore(
|
||||
withState({
|
||||
currentPlaylist: PLAYLIST,
|
||||
@@ -41,31 +55,13 @@ const TestPlayerStore = signalStore(
|
||||
has_files: true,
|
||||
title: 'Original Title',
|
||||
category_id: 'vod',
|
||||
} as {
|
||||
id: string;
|
||||
cmd: string;
|
||||
has_files?: boolean;
|
||||
title?: string;
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
logo?: string;
|
||||
category_id?: string;
|
||||
cover?: string;
|
||||
},
|
||||
} as SelectedTestItem,
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
setSelectedContentType(type: 'vod' | 'series' | 'itv' | 'radio') {
|
||||
patchState(store, { selectedContentType: type });
|
||||
},
|
||||
setSelectedItem(item: {
|
||||
id: string;
|
||||
cmd: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
logo?: string;
|
||||
category_id?: string;
|
||||
}) {
|
||||
setSelectedItem(item: SelectedTestItem) {
|
||||
patchState(store, { selectedItem: item });
|
||||
},
|
||||
})),
|
||||
@@ -339,6 +335,183 @@ describe('withStalkerPlayer', () => {
|
||||
expect(playback.origin).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('temporary-link semantics', () => {
|
||||
const CHANNEL = {
|
||||
id: '10001',
|
||||
cmd: 'ffrt3 http://cdn.example/live/10001.m3u8',
|
||||
name: 'Static TV',
|
||||
o_name: 'Static TV',
|
||||
logo: 'static-tv.png',
|
||||
category_id: '1001',
|
||||
};
|
||||
|
||||
it('plays an unflagged ITV channel straight from its static cmd', async () => {
|
||||
store.setSelectedContentType('itv');
|
||||
|
||||
const playback = await store.resolveItvPlayback({
|
||||
...CHANNEL,
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
});
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example/live/10001.m3u8'
|
||||
);
|
||||
expect(playback.isLive).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
|
||||
'mints a temporary link for an ITV channel with %s set',
|
||||
async (flag) => {
|
||||
store.setSelectedContentType('itv');
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce({
|
||||
js: { cmd: 'ffmpeg http://cdn.example/tmp/10001.m3u8' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveItvPlayback({
|
||||
...CHANNEL,
|
||||
[flag]: '1',
|
||||
});
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
cmd: CHANNEL.cmd,
|
||||
type: 'itv',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example/tmp/10001.m3u8'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('mints a temporary link for a flagged radio station with a playable cmd', async () => {
|
||||
// Before the flags were read, a directly playable radio command
|
||||
// always bypassed create_link — a proxied station then played a
|
||||
// URL the portal never intended to serve.
|
||||
store.setSelectedContentType('radio');
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce({
|
||||
js: { cmd: 'http://cdn.example/tmp/jazz.mp3' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveRadioPlayback({
|
||||
id: 'radio-3',
|
||||
cmd: 'ifm https://stream.example/jazz.mp3',
|
||||
name: 'Jazz FM',
|
||||
o_name: 'Jazz FM',
|
||||
logo: 'jazz.png',
|
||||
category_id: '4001',
|
||||
use_http_tmp_link: '1',
|
||||
});
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
type: 'radio',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(playback.streamUrl).toBe('http://cdn.example/tmp/jazz.mp3');
|
||||
});
|
||||
|
||||
it('mints a link for a flagged VOD row with a directly playable cmd', async () => {
|
||||
// The VOD row reaches this method through
|
||||
// `buildStalkerSelectedVodItem`, a whitelist — if it ever drops
|
||||
// the flags again, this row reads as unflagged and the static
|
||||
// path silently plays the portal's non-final URL.
|
||||
store.setSelectedContentType('vod');
|
||||
store.setSelectedItem({
|
||||
id: '42',
|
||||
cmd: 'ffrt3 http://cdn.example/movie.mkv',
|
||||
title: 'Flagged Movie',
|
||||
category_id: 'vod',
|
||||
use_http_tmp_link: '1',
|
||||
});
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce({
|
||||
js: { cmd: 'http://cdn.example/tmp/movie.mkv?tok=1' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveVodPlayback(
|
||||
undefined,
|
||||
'Flagged Movie'
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example/tmp/movie.mkv?tok=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('plays an unflagged VOD row from its static cmd', async () => {
|
||||
store.setSelectedContentType('vod');
|
||||
store.setSelectedItem({
|
||||
id: '43',
|
||||
cmd: 'ffrt3 http://cdn.example/movie.mkv',
|
||||
title: 'Static Movie',
|
||||
category_id: 'vod',
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
});
|
||||
|
||||
const playback = await store.resolveVodPlayback(
|
||||
undefined,
|
||||
'Static Movie'
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(playback.streamUrl).toBe('http://cdn.example/movie.mkv');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['static', { use_http_tmp_link: '0' }, undefined],
|
||||
[
|
||||
'minted',
|
||||
{ use_http_tmp_link: '1' },
|
||||
{ js: { cmd: 'http://cdn.example/tmp/10001.m3u8?tok=SECRET' } },
|
||||
],
|
||||
])(
|
||||
'stores the portal cmd, never the %s stream URL, in recently viewed',
|
||||
async (_label, flags, response) => {
|
||||
// A temporary link dies after ~5 s, so a replayed one is worse
|
||||
// than useless — the row has to keep the `cmd` and re-resolve.
|
||||
store.setSelectedContentType('itv');
|
||||
if (response) {
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce(response);
|
||||
}
|
||||
|
||||
await store.resolveItvPlayback({ ...CHANNEL, ...flags });
|
||||
|
||||
expect(
|
||||
playlistService.addPortalRecentlyViewed
|
||||
).toHaveBeenCalledWith(
|
||||
PLAYLIST._id,
|
||||
expect.objectContaining({
|
||||
id: '10001',
|
||||
cmd: CHANNEL.cmd,
|
||||
})
|
||||
);
|
||||
const [, persisted] =
|
||||
playlistService.addPortalRecentlyViewed.mock.calls[0];
|
||||
expect(JSON.stringify(persisted)).not.toContain('SECRET');
|
||||
expect(JSON.stringify(persisted)).not.toContain('/tmp/');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches the portal header set to radio playback resolved from the portal', async () => {
|
||||
const session = TestBed.inject(StalkerSessionService) as unknown as {
|
||||
getCachedToken: jest.Mock;
|
||||
|
||||
+38
-26
@@ -32,14 +32,16 @@ import {
|
||||
fetchStalkerExpireDate,
|
||||
fetchStalkerMovieFileId,
|
||||
fetchStalkerPlaybackLink,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
hasStalkerLinkFlagEvidence,
|
||||
shouldResolveMovieFileId,
|
||||
type StalkerLinkFlagSource,
|
||||
} from '../utils';
|
||||
|
||||
type StalkerPlayableItem = StalkerPortalItem & {
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
};
|
||||
type StalkerPlayableItem = StalkerPortalItem &
|
||||
StalkerLinkFlagSource & {
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Playback/link/player concern methods.
|
||||
@@ -189,6 +191,7 @@ export function withStalkerPlayer() {
|
||||
storeState.selectedContentType(),
|
||||
cmd: cmdToUse,
|
||||
series: episodeNum,
|
||||
linkFlags: item,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -265,6 +268,7 @@ export function withStalkerPlayer() {
|
||||
storeState.selectedContentType(),
|
||||
cmd: item.cmd,
|
||||
forcedContentType: 'itv',
|
||||
linkFlags: item,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -318,26 +322,32 @@ export function withStalkerPlayer() {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
|
||||
let streamUrl = normalizeStalkerPlaybackCommand(item.cmd);
|
||||
if (
|
||||
!streamUrl.startsWith('http://') &&
|
||||
!streamUrl.startsWith('https://')
|
||||
) {
|
||||
streamUrl = await fetchStalkerPlaybackLink(
|
||||
requestDeps,
|
||||
{
|
||||
playlist,
|
||||
selectedContentType:
|
||||
storeState.selectedContentType(),
|
||||
cmd: item.cmd,
|
||||
forcedContentType: 'radio',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!streamUrl) {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
// Radio has always skipped `create_link` for a directly
|
||||
// playable command. Handing the row to the shared decision
|
||||
// adds the flags, so a station the portal proxies now gets
|
||||
// its temporary link instead of a dead static URL — but a
|
||||
// row carrying no flags at all must keep the old rule
|
||||
// rather than start minting, or a portal whose radio
|
||||
// `create_link` never worked would lose playback it has.
|
||||
// ITV and VOD have no such history and stay conservative.
|
||||
const radioLinkFlags = hasStalkerLinkFlagEvidence(item)
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
};
|
||||
const streamUrl = await fetchStalkerPlaybackLink(
|
||||
requestDeps,
|
||||
{
|
||||
playlist,
|
||||
selectedContentType:
|
||||
storeState.selectedContentType(),
|
||||
cmd: item.cmd,
|
||||
forcedContentType: 'radio',
|
||||
linkFlags: radioLinkFlags,
|
||||
}
|
||||
);
|
||||
|
||||
recordRecentlyViewed(
|
||||
item,
|
||||
@@ -384,7 +394,8 @@ export function withStalkerPlayer() {
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string,
|
||||
series?: number
|
||||
series?: number,
|
||||
linkFlags?: StalkerLinkFlagSource | null
|
||||
) {
|
||||
return fetchStalkerPlaybackLink(requestDeps, {
|
||||
playlist: createRequestPlaylist(
|
||||
@@ -395,6 +406,7 @@ export function withStalkerPlayer() {
|
||||
storeState.selectedContentType(),
|
||||
cmd,
|
||||
series,
|
||||
linkFlags,
|
||||
});
|
||||
},
|
||||
async getExpireDate() {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './stalker-collection-persistence.utils';
|
||||
export * from './stalker-link-semantics.utils';
|
||||
export * from './stalker-playback-command.utils';
|
||||
export * from './stalker-player-request.utils';
|
||||
export * from './stalker-request.utils';
|
||||
export * from './stalker-content-mappers';
|
||||
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
isStalkerPortalFlagEnabled,
|
||||
requiresStalkerTemporaryLink,
|
||||
resolveStalkerStaticPlaybackUrl,
|
||||
} from './stalker-link-semantics.utils';
|
||||
|
||||
describe('stalker-link-semantics', () => {
|
||||
describe('isStalkerPortalFlagEnabled', () => {
|
||||
it.each([
|
||||
['1', true],
|
||||
[1, true],
|
||||
[true, true],
|
||||
['true', true],
|
||||
['0', false],
|
||||
[0, false],
|
||||
[false, false],
|
||||
['false', false],
|
||||
['', false],
|
||||
[' ', false],
|
||||
[null, false],
|
||||
[undefined, false],
|
||||
[Number.NaN, false],
|
||||
])('reads %p as %p', (value, expected) => {
|
||||
expect(isStalkerPortalFlagEnabled(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiresStalkerTemporaryLink', () => {
|
||||
it('is false for a row that sets neither flag', () => {
|
||||
expect(
|
||||
requiresStalkerTemporaryLink({
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the row carries no flags at all', () => {
|
||||
expect(requiresStalkerTemporaryLink({})).toBe(false);
|
||||
expect(requiresStalkerTemporaryLink(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
|
||||
'is true when %s is set',
|
||||
(flag) => {
|
||||
expect(requiresStalkerTemporaryLink({ [flag]: '1' })).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('resolveStalkerStaticPlaybackUrl', () => {
|
||||
it('plays an unflagged absolute command and strips the solution prefix', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0', use_load_balancing: '0' },
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://cdn.example/live/42.m3u8');
|
||||
});
|
||||
|
||||
it('accepts a bare URL with no solution prefix', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'https://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('https://cdn.example/live/42.m3u8');
|
||||
});
|
||||
|
||||
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
|
||||
'defers to create_link when %s is set',
|
||||
(flag) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ [flag]: '1' },
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
// The VOD has_files rewrite produces exactly this shape.
|
||||
['/media/file_42.mpg'],
|
||||
['?token=abc'],
|
||||
['ffrt4://ch/live/10001/index.m3u8'],
|
||||
['rtmp://cdn.example/live/42'],
|
||||
[''],
|
||||
[' '],
|
||||
])('defers to create_link for the unresolvable command %p', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ffrt3 http://localhost/ch/1234_'],
|
||||
['http://127.0.0.1:8080/ch/1234_'],
|
||||
['http://0.0.0.0/ch/1234_'],
|
||||
['http://[::1]/ch/1234_'],
|
||||
])('defers to create_link for the portal-local address %p', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('gives no verdict when the caller has no row to read flags from', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
undefined,
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
null,
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('gives no verdict for a row carrying neither flag key', () => {
|
||||
// A Favorite persisted before these flags were carried was
|
||||
// stripped of them by `buildStalkerSelectedVodItem`'s whitelist,
|
||||
// so it is indistinguishable from a row the portal marked
|
||||
// unflagged. There is no migration for those rows, so absence has
|
||||
// to read as "no evidence" rather than "no temporary link".
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ id: '42', cmd: 'x' } as never,
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: undefined, use_load_balancing: null },
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['http://127.0.0.2/ch/1234_'],
|
||||
['http://127.1.2.3:8080/ch/1234_'],
|
||||
['http://127.255.255.255/ch/1234_'],
|
||||
])('treats the whole 127.0.0.0/8 range as portal-local (%p)', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// `URL` keeps the brackets and normalizes the address, so these
|
||||
// reach the guard as `[::]`, `[::1]` and `[::ffff:7f00:1]`.
|
||||
['http://[::]/ch/1234_'],
|
||||
['http://[::1]/ch/1234_'],
|
||||
['http://[0:0:0:0:0:0:0:1]/ch/1234_'],
|
||||
['http://[::ffff:127.0.0.1]/ch/1234_'],
|
||||
['http://[::ffff:7f00:1]/ch/1234_'],
|
||||
['http://[::ffff:127.255.255.255]/ch/1234_'],
|
||||
['http://[::ffff:0.0.0.0]/ch/1234_'],
|
||||
])('treats the IPv6 portal-local form %p as local', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ffrt3 http://localhost./ch/1234_'],
|
||||
['http://LocalHost./ch/1234_'],
|
||||
])('strips the DNS root dot before classifying %p', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['HTTP://cdn.example/live/42.m3u8'],
|
||||
['ffrt3 HTTPS://cdn.example/live/42.m3u8'],
|
||||
['hTTp://cdn.example/live/42.m3u8'],
|
||||
])('accepts the case-insensitive scheme %p', (cmd) => {
|
||||
// RFC 3986 makes the scheme case-insensitive. A case-sensitive
|
||||
// test failed safe (it minted a link) but defeated the contract
|
||||
// for a portal that spells it this way.
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toMatch(/cdn\.example\/live\/42\.m3u8$/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['http:///ch/1234_'],
|
||||
['ffrt3 https:///ch/1234_'],
|
||||
])('defers to create_link for the authority-less url %p', (cmd) => {
|
||||
// `new URL('http:///ch/1')` reports the host as `ch` — a malformed
|
||||
// command would otherwise reach the player as a nonsense address
|
||||
// rather than going to the portal to be resolved.
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a routable IPv6 host', () => {
|
||||
// 2001:db8::1 is documentation space, but it is routable as far as
|
||||
// this guard is concerned — only local placeholders are rejected.
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://[2001:db8::1]/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://[2001:db8::1]/live/42.m3u8');
|
||||
});
|
||||
|
||||
it('keeps an IPv4-mapped address that is not loopback', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://[::ffff:203.0.113.7]/live/42.m3u8'
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// RFC 6761 §6.3 reserves the whole `.localhost` suffix.
|
||||
['http://stream.localhost/ch/1234_'],
|
||||
['ffrt3 http://a.b.localhost/ch/1234_'],
|
||||
['http://stream.localhost./ch/1234_'],
|
||||
// Conventional /etc/hosts alias for 127.0.0.1.
|
||||
['http://localhost.localdomain/ch/1234_'],
|
||||
])('treats the localhost name %p as portal-local', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mistake a localhost-prefixed host for a localhost name', () => {
|
||||
// Only the SUFFIX is reserved — `localhost.cdn.example` is an
|
||||
// ordinary routable name and must keep playing statically.
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://localhost.cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://localhost.cdn.example/live/42.m3u8');
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://notlocalhost/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://notlocalhost/live/42.m3u8');
|
||||
});
|
||||
|
||||
it('does not mistake a non-loopback 127-lookalike for loopback', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://127.0.0.1.cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://127.0.0.1.cdn.example/live/42.m3u8');
|
||||
});
|
||||
|
||||
it('keeps a non-loopback host that merely looks local', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://localhost.cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://localhost.cdn.example/live/42.m3u8');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
isPlayableHttpUrl,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
} from './stalker-playback-command.utils';
|
||||
|
||||
/**
|
||||
* The two catalog flags that decide whether a row needs a temporary link.
|
||||
*
|
||||
* Reference behaviour (portal `player.js`, mirrored by Kodi's pvr.stalker):
|
||||
* a client calls `create_link` only when the row asks for it — either because
|
||||
* the portal proxies the stream through a per-session temporary URL
|
||||
* (`use_http_tmp_link`) or because it picks a storage server per request
|
||||
* (`use_load_balancing`). Every other row plays the static `cmd` that
|
||||
* `get_all_channels` / `get_ordered_list` already returned.
|
||||
*
|
||||
* Portals send these as `'1'`/`'0'` strings, `1`/`0` numbers or booleans, so
|
||||
* the values arrive untyped.
|
||||
*/
|
||||
export interface StalkerLinkFlagSource {
|
||||
use_http_tmp_link?: unknown;
|
||||
use_load_balancing?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that can only mean "the portal itself". A `cmd` such as
|
||||
* `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, never an
|
||||
* address the set-top box could open, so it always needs resolving.
|
||||
*/
|
||||
const PORTAL_LOCAL_HOSTNAMES = new Set([
|
||||
'localhost',
|
||||
// Conventional `/etc/hosts` alias for 127.0.0.1 on most Linux systems.
|
||||
'localhost.localdomain',
|
||||
'0.0.0.0',
|
||||
'::1',
|
||||
'::',
|
||||
]);
|
||||
|
||||
/**
|
||||
* RFC 6761 §6.3 reserves `localhost` AND every name ending in `.localhost`
|
||||
* for the loopback interface, and resolvers honour it — so `stream.localhost`
|
||||
* would reach the player's own machine.
|
||||
*/
|
||||
const LOCALHOST_SUFFIX = '.localhost';
|
||||
|
||||
/** IPv4 reserves all of `127.0.0.0/8` for loopback, not just `127.0.0.1`. */
|
||||
const IPV4_LOOPBACK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
||||
|
||||
/** How `URL` normalizes an IPv4-mapped address: `::ffff:7f00:1`. */
|
||||
const IPV6_MAPPED_IPV4 = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/;
|
||||
|
||||
/**
|
||||
* Whether the host can only mean the machine resolving it — the portal when it
|
||||
* wrote the command, the player if we took it literally.
|
||||
*
|
||||
* `URL.hostname` keeps IPv6 in brackets and normalizes the address, so
|
||||
* `[0:0:0:0:0:0:0:1]` arrives as `[::1]` and an IPv4-mapped `127.0.0.1` as hex
|
||||
* (`[::ffff:7f00:1]`) — both are matched here rather than relying on the
|
||||
* portal to spell them the obvious way.
|
||||
*/
|
||||
function isPortalLocalHostname(hostname: string): boolean {
|
||||
// A trailing dot is the DNS root and resolves identically, but `URL` keeps
|
||||
// it for names (`localhost.`) while dropping it for IP literals — so an
|
||||
// exact-name check has to strip it or `http://localhost./ch/1_` reads as a
|
||||
// remote host.
|
||||
const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
if (
|
||||
PORTAL_LOCAL_HOSTNAMES.has(host) ||
|
||||
host.endsWith(LOCALHOST_SUFFIX) ||
|
||||
IPV4_LOOPBACK.test(host)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Dotted IPv4-mapped form, for any engine that does not rewrite it to hex.
|
||||
if (host.startsWith('::ffff:')) {
|
||||
const tail = host.slice('::ffff:'.length);
|
||||
if (IPV4_LOOPBACK.test(tail) || tail === '0.0.0.0') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = IPV6_MAPPED_IPV4.exec(host);
|
||||
if (!mapped) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The whole of 127.0.0.0/8 shares the 0x7f high byte (127.0.0.1 is
|
||||
// 7f00:0001); `::ffff:0:0` is the mapped unspecified address.
|
||||
const high = Number.parseInt(mapped[1], 16);
|
||||
const low = Number.parseInt(mapped[2], 16);
|
||||
return high >>> 8 === 0x7f || (high === 0 && low === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the row can speak for itself about temporary links.
|
||||
*
|
||||
* A stock portal returns both flags on every ITV/VOD row, so their PRESENCE
|
||||
* is the provenance signal — and it is the only one available, because rows
|
||||
* persisted into Favorites/Recently Viewed before these flags were carried
|
||||
* were stripped of them by `buildStalkerSelectedVodItem`'s whitelist. Without
|
||||
* this check a legacy snapshot would be indistinguishable from a row the
|
||||
* portal genuinely marked unflagged, and would take the static path on a
|
||||
* command that may still need resolving. There is no migration or provenance
|
||||
* marker for those rows, so absence has to mean "no evidence", not "no".
|
||||
*/
|
||||
export function hasStalkerLinkFlagEvidence(
|
||||
source: StalkerLinkFlagSource | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
source?.use_http_tmp_link != null || source?.use_load_balancing != null
|
||||
);
|
||||
}
|
||||
|
||||
/** Truthiness for portal flags, which arrive as strings, numbers or booleans. */
|
||||
export function isStalkerPortalFlagEnabled(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) && value !== 0;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalized !== '0' && normalized !== 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the row explicitly asks the client to mint a temporary link.
|
||||
*/
|
||||
export function requiresStalkerTemporaryLink(
|
||||
source: StalkerLinkFlagSource | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
isStalkerPortalFlagEnabled(source?.use_http_tmp_link) ||
|
||||
isStalkerPortalFlagEnabled(source?.use_load_balancing)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The playable URL for a row that does NOT need `create_link`, or `null` when
|
||||
* the portal has to resolve it.
|
||||
*
|
||||
* `null` is returned for every shape a client cannot resolve on its own, which
|
||||
* is deliberately wider than the flag check alone — the flags are the rule,
|
||||
* these guards only ever push a row back onto today's `create_link` path and
|
||||
* so cannot regress a portal that works now:
|
||||
*
|
||||
* - no row at all, or a row carrying neither flag key — no evidence, so no
|
||||
* verdict (see {@link hasStalkerLinkFlagEvidence}: a row persisted before
|
||||
* these flags were carried looks identical to one the portal marked
|
||||
* unflagged);
|
||||
* - either flag set — the portal asked for a temporary link;
|
||||
* - a relative (`/media/file_12.mpg`) or query-only (`?token=…`) command —
|
||||
* only `create_link` turns those into an address, and the VOD `has_files`
|
||||
* rewrite produces exactly the first shape;
|
||||
* - a non-HTTP scheme (`ffrt4://ch/live/…`) — a portal-internal pseudo-URL —
|
||||
* or an HTTP command with no authority (`http:///ch/1`), which `URL` would
|
||||
* quietly reinterpret as the host `ch`;
|
||||
* - a portal-local host — a placeholder only the portal could resolve (see
|
||||
* {@link isPortalLocalHostname}, which covers rather more than the obvious
|
||||
* `localhost`).
|
||||
*/
|
||||
export function resolveStalkerStaticPlaybackUrl(
|
||||
source: StalkerLinkFlagSource | null | undefined,
|
||||
cmd: string
|
||||
): string | null {
|
||||
if (
|
||||
!hasStalkerLinkFlagEvidence(source) ||
|
||||
requiresStalkerTemporaryLink(source)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = normalizeStalkerPlaybackCommand(cmd);
|
||||
if (!isPlayableHttpUrl(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isPortalLocalHostname(new URL(url).hostname.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
hasHttpScheme,
|
||||
isPlayableHttpUrl,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
resolveStalkerPlaybackUrl,
|
||||
} from './stalker-playback-command.utils';
|
||||
|
||||
const PORTAL = 'http://demo.example/stalker_portal/server/load.php';
|
||||
|
||||
describe('stalker-playback-command.utils', () => {
|
||||
describe('hasHttpScheme', () => {
|
||||
it.each([
|
||||
['http://a/b', true],
|
||||
['https://a/b', true],
|
||||
['HTTP://a/b', true],
|
||||
['HtTpS://a/b', true],
|
||||
['ffrt4://a/b', false],
|
||||
['rtmp://a/b', false],
|
||||
['/media/1.mpg', false],
|
||||
['', false],
|
||||
])('reads %p as %p', (value, expected) => {
|
||||
expect(hasHttpScheme(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlayableHttpUrl', () => {
|
||||
it.each([
|
||||
['http://cdn.example/a.ts', true],
|
||||
['HTTPS://cdn.example/a.ts', true],
|
||||
['http://[::1]/a.ts', true],
|
||||
// `new URL` reinterprets the first path segment as the host here.
|
||||
['http:///ch/1', false],
|
||||
['https:///ch/1', false],
|
||||
['ffrt4://ch/1', false],
|
||||
['/media/1.mpg', false],
|
||||
])('reads %p as %p', (value, expected) => {
|
||||
expect(isPlayableHttpUrl(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeStalkerPlaybackCommand', () => {
|
||||
it('splits the solution prefix off a lowercase url', () => {
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffrt3 http://cdn.example/a.ts')
|
||||
).toBe('http://cdn.example/a.ts');
|
||||
});
|
||||
|
||||
it('splits the solution prefix off an uppercase scheme too', () => {
|
||||
// RFC 3986 makes the scheme case-insensitive; a case-sensitive
|
||||
// test returned the whole `ffrt3 HTTP://…` string as if it were
|
||||
// the URL.
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffrt3 HTTP://cdn.example/a.ts')
|
||||
).toBe('HTTP://cdn.example/a.ts');
|
||||
});
|
||||
|
||||
it('leaves a bare command untouched', () => {
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffrt4://ch/live/1/index.m3u8')
|
||||
).toBe('ffrt4://ch/live/1/index.m3u8');
|
||||
});
|
||||
|
||||
it('keeps relative and query-only replies', () => {
|
||||
expect(
|
||||
normalizeStalkerPlaybackCommand('ffmpeg /media/file_1.mpg')
|
||||
).toBe('/media/file_1.mpg');
|
||||
expect(normalizeStalkerPlaybackCommand('ffmpeg ?token=abc')).toBe(
|
||||
'?token=abc'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStalkerPlaybackUrl', () => {
|
||||
it('returns an absolute reply as-is regardless of scheme case', () => {
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(
|
||||
PORTAL,
|
||||
'/media/source.mpg',
|
||||
'ffmpeg HTTPS://cdn.example/a.ts'
|
||||
)
|
||||
).toBe('HTTPS://cdn.example/a.ts');
|
||||
});
|
||||
|
||||
it('resolves a relative reply against the installation base', () => {
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(
|
||||
PORTAL,
|
||||
'/media/source.mpg',
|
||||
'/media/video_7.mpg'
|
||||
)
|
||||
).toBe('http://demo.example/stalker_portal/media/video_7.mpg');
|
||||
});
|
||||
|
||||
it('appends a query-only reply to an uppercase-scheme command', () => {
|
||||
// The original `cmd` is already an address, so the query belongs
|
||||
// on it rather than on the portal base.
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(
|
||||
PORTAL,
|
||||
'auto HTTP://cdn.example/live/9.m3u8',
|
||||
'?token=xyz'
|
||||
)
|
||||
).toBe('HTTP://cdn.example/live/9.m3u8?token=xyz');
|
||||
});
|
||||
|
||||
it('appends a query-only reply to a relative command via the base', () => {
|
||||
expect(
|
||||
resolveStalkerPlaybackUrl(PORTAL, '/ch/9', '?token=xyz')
|
||||
).toBe('http://demo.example/stalker_portal/ch/9?token=xyz');
|
||||
});
|
||||
|
||||
it('returns an empty string for an empty reply', () => {
|
||||
expect(resolveStalkerPlaybackUrl(PORTAL, '/ch/9', '')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Pure `cmd` handling shared by every Stalker playback path.
|
||||
*
|
||||
* Kept apart from the request helpers so the link-semantics decision (which
|
||||
* needs the normalizer) and the request helpers (which need the decision) do
|
||||
* not import each other.
|
||||
*/
|
||||
|
||||
/**
|
||||
* RFC 3986 makes the scheme case-insensitive and portals are inconsistent
|
||||
* about it, so every scheme test here goes through this rather than
|
||||
* `startsWith('http://')` — which would send `HTTP://…` down the
|
||||
* "not a URL" branch of whichever check it hit.
|
||||
*/
|
||||
const HTTP_SCHEME = /^https?:\/\//i;
|
||||
|
||||
/**
|
||||
* The authority must be non-empty: `http:///ch/1` silently reinterprets the
|
||||
* first path segment as the host (`URL` reports `ch`), so a malformed command
|
||||
* would otherwise be handed to the player as a nonsense address instead of
|
||||
* going to the portal to be resolved.
|
||||
*/
|
||||
const HTTP_URL_WITH_AUTHORITY = /^https?:\/\/[^/]/i;
|
||||
|
||||
export function hasHttpScheme(value: string): boolean {
|
||||
return HTTP_SCHEME.test(value);
|
||||
}
|
||||
|
||||
/** A command that is usable as an address on its own. */
|
||||
export function isPlayableHttpUrl(value: string): boolean {
|
||||
return HTTP_URL_WITH_AUTHORITY.test(value);
|
||||
}
|
||||
|
||||
export function normalizeStalkerPlaybackCommand(value: string): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const splitAt = trimmed.indexOf(' ');
|
||||
if (splitAt > 0) {
|
||||
const candidate = trimmed.slice(splitAt + 1).trim();
|
||||
if (
|
||||
hasHttpScheme(candidate) ||
|
||||
candidate.startsWith('/') ||
|
||||
candidate.startsWith('?')
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveStalkerPlaybackUrl(
|
||||
portalUrl: string,
|
||||
originalCmd: string,
|
||||
responseCmd: string
|
||||
): string {
|
||||
const url = normalizeStalkerPlaybackCommand(responseCmd);
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (hasHttpScheme(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const portalUrlObj = new URL(portalUrl);
|
||||
// The installation base is the endpoint path MINUS the API suffix
|
||||
// discovery appended (`/portal.php`, `/server/load.php`) — endpoint
|
||||
// discovery can persist arbitrary nested installations
|
||||
// (`/cp/server/load.php`), so a fixed segment allowlist would
|
||||
// resolve `/media/...` against the wrong root. The legacy marker
|
||||
// segments stay as the fallback for URLs that carry neither suffix.
|
||||
const endpointPath = portalUrlObj.pathname;
|
||||
let basePath = '';
|
||||
const apiSuffix = /\/(?:portal\.php|server\/load\.php|[^/]*\.php)$/i;
|
||||
if (apiSuffix.test(endpointPath)) {
|
||||
basePath = endpointPath.replace(apiSuffix, '');
|
||||
} else {
|
||||
const pathParts = endpointPath.split('/');
|
||||
for (let index = 0; index < pathParts.length; index += 1) {
|
||||
if (
|
||||
pathParts[index] === 'stalker_portal' ||
|
||||
pathParts[index] === 'c' ||
|
||||
pathParts[index] === 'portal'
|
||||
) {
|
||||
basePath = '/' + pathParts.slice(1, index + 1).join('/');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (url.startsWith('?')) {
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd);
|
||||
if (hasHttpScheme(normalizedCmd)) {
|
||||
return `${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
return `${portalUrlObj.origin}${basePath}${url}`;
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function shouldResolveMovieFileId(
|
||||
item: { has_files?: unknown } | null | undefined,
|
||||
cmd: string
|
||||
): boolean {
|
||||
return (
|
||||
item?.has_files !== undefined &&
|
||||
!cmd.includes('://') &&
|
||||
cmd.includes('/media/') &&
|
||||
!cmd.includes('/media/file_')
|
||||
);
|
||||
}
|
||||
+284
-2
@@ -1,10 +1,10 @@
|
||||
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { shouldResolveMovieFileId } from './stalker-playback-command.utils';
|
||||
import {
|
||||
fetchStalkerExpireDate,
|
||||
fetchStalkerMovieFileId,
|
||||
fetchStalkerPlaybackLink,
|
||||
shouldResolveMovieFileId,
|
||||
} from './stalker-player-request.utils';
|
||||
|
||||
const PLAYLIST = {
|
||||
@@ -22,7 +22,10 @@ describe('stalker-player-request.utils', () => {
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
};
|
||||
let stalkerSession: Pick<StalkerSessionService, 'makeAuthenticatedRequest'>;
|
||||
let stalkerSession: Pick<
|
||||
StalkerSessionService,
|
||||
'makeAuthenticatedRequest' | 'ensureToken'
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = {
|
||||
@@ -30,6 +33,7 @@ describe('stalker-player-request.utils', () => {
|
||||
};
|
||||
stalkerSession = {
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
ensureToken: jest.fn().mockResolvedValue({ token: null }),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -180,6 +184,284 @@ describe('stalker-player-request.utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('temporary-link semantics', () => {
|
||||
const deps = () => ({
|
||||
dataService: dataService as never,
|
||||
stalkerSession: stalkerSession as StalkerSessionService,
|
||||
});
|
||||
|
||||
it('establishes the session before returning a static url on a full portal', async () => {
|
||||
// Minting the link used to be what authenticated. The global
|
||||
// collection detail sets playlist + item straight from a
|
||||
// persisted row, so a cold-start VOD would otherwise play a
|
||||
// same-host gated stream with no Bearer token.
|
||||
const fullPortal = {
|
||||
...PLAYLIST,
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
(stalkerSession.ensureToken as jest.Mock).mockResolvedValue({
|
||||
token: 'TOKEN-WARM',
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: fullPortal,
|
||||
selectedContentType: 'vod',
|
||||
cmd: 'ffrt3 http://demo.example/movies/1.mkv',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://demo.example/movies/1.mkv');
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: PLAYLIST._id })
|
||||
);
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to create_link for a portal-owned url with no session', async () => {
|
||||
// Serving a same-host static URL without a token means serving a
|
||||
// known 401. The request path both mints a URL carrying its own
|
||||
// token and is the only path that can observe a failure and
|
||||
// trigger the lazy portal repair.
|
||||
(stalkerSession.ensureToken as jest.Mock).mockResolvedValue({
|
||||
token: null,
|
||||
});
|
||||
// A full portal dispatches through the authenticated session, not
|
||||
// the raw IPC bridge.
|
||||
(
|
||||
stalkerSession.makeAuthenticatedRequest as jest.Mock
|
||||
).mockResolvedValue({
|
||||
js: { cmd: 'http://demo.example/tmp/1.mkv?tok=1' },
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: {
|
||||
...PLAYLIST,
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta,
|
||||
selectedContentType: 'vod',
|
||||
cmd: 'ffrt3 http://demo.example/movies/1.mkv',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://demo.example/tmp/1.mkv?tok=1');
|
||||
expect(
|
||||
stalkerSession.makeAuthenticatedRequest
|
||||
).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('serves a foreign-host static url without contacting the portal', async () => {
|
||||
// A CDN stream never needs the portal session, so it must not be
|
||||
// pushed onto the request path — and must not wait on a handshake
|
||||
// either: that is up to 15 s per request against a portal that may
|
||||
// be offline while the CDN is reachable.
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: {
|
||||
...PLAYLIST,
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta,
|
||||
selectedContentType: 'vod',
|
||||
cmd: 'ffrt3 http://cdn.example/movies/1.mkv',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://cdn.example/movies/1.mkv');
|
||||
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handshakes against a repaired endpoint, not the stale one', async () => {
|
||||
// `executeStalkerRequest` applies the repair override on its first
|
||||
// line, so the static path must too — handshaking against the
|
||||
// configuration a repair has already proven broken would strand
|
||||
// the session.
|
||||
const stale = {
|
||||
...PLAYLIST,
|
||||
portalUrl: 'http://demo.example/portal.php',
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
const repaired = {
|
||||
...stale,
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
} as PlaylistMeta;
|
||||
// Portal-owned URL: only those reach the handshake now, since a
|
||||
// foreign host never needs the session.
|
||||
(stalkerSession.ensureToken as jest.Mock).mockResolvedValue({
|
||||
token: 'TOKEN-REPAIRED',
|
||||
});
|
||||
|
||||
await fetchStalkerPlaybackLink(
|
||||
{
|
||||
dataService: dataService as never,
|
||||
stalkerSession: stalkerSession as StalkerSessionService,
|
||||
portalRepair: {
|
||||
applyOverride: jest.fn().mockReturnValue(repaired),
|
||||
shouldAttemptRepair: jest.fn().mockReturnValue(false),
|
||||
repairPortal: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
playlist: stale,
|
||||
selectedContentType: 'itv',
|
||||
cmd: 'ffrt3 http://demo.example/live/42.m3u8',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ portalUrl: repaired.portalUrl })
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the handshake for a simple portal', async () => {
|
||||
// The command has to be PORTAL-OWNED, or the foreign-host early
|
||||
// return would skip the handshake by itself and this would pass
|
||||
// without saying anything about portal mode. `PLAYLIST` is a
|
||||
// simple portal, so the skip here can only come from the mode.
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'itv',
|
||||
cmd: 'ffrt3 http://demo.example/live/42.m3u8',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://demo.example/live/42.m3u8');
|
||||
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to create_link when the handshake throws', async () => {
|
||||
// A throwing handshake must not propagate — it leaves the session
|
||||
// unusable, and a portal-owned URL then goes to `create_link`
|
||||
// rather than being served as a known 401. The command has to be
|
||||
// portal-owned for the handshake to run at all.
|
||||
(
|
||||
stalkerSession.ensureToken as jest.Mock
|
||||
).mockRejectedValue(new Error('portal down'));
|
||||
(
|
||||
stalkerSession.makeAuthenticatedRequest as jest.Mock
|
||||
).mockResolvedValue({
|
||||
js: { cmd: 'http://demo.example/tmp/1.mkv?tok=1' },
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: {
|
||||
...PLAYLIST,
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta,
|
||||
selectedContentType: 'vod',
|
||||
cmd: 'ffrt3 http://demo.example/movies/1.mkv',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
});
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalled();
|
||||
expect(streamUrl).toBe('http://demo.example/tmp/1.mkv?tok=1');
|
||||
});
|
||||
|
||||
it('plays the static cmd of an unflagged row without asking the portal', async () => {
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'itv',
|
||||
cmd: 'ffrt3 http://cdn.example/live/42.m3u8',
|
||||
linkFlags: {
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://cdn.example/live/42.m3u8');
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['use_http_tmp_link', 'use_load_balancing'] as const)(
|
||||
'mints a temporary link when %s is set',
|
||||
async (flag) => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'http://cdn.example/tmp/42.m3u8?tok=1' },
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'itv',
|
||||
cmd: 'ffrt3 http://cdn.example/live/42.m3u8',
|
||||
linkFlags: { [flag]: '1' },
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://cdn.example/tmp/42.m3u8?tok=1');
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('mints a link when the caller supplies no flags', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'http://cdn.example/tmp/42.m3u8' },
|
||||
});
|
||||
|
||||
await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'itv',
|
||||
cmd: 'ffrt3 http://cdn.example/live/42.m3u8',
|
||||
});
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('always mints a link for an episode, whose cmd addresses the series', async () => {
|
||||
// `series` selects the episode server-side, so the parent row's
|
||||
// static cmd is not an answer even when it is unflagged.
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'http://cdn.example/tmp/ep3.m3u8' },
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'series',
|
||||
cmd: 'ffrt3 http://cdn.example/series/7.m3u8',
|
||||
series: 3,
|
||||
linkFlags: {
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe('http://cdn.example/tmp/ep3.m3u8');
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ series: '3' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('still mints a link for a relative unflagged VOD command', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: '/media/video_77.mpg' },
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(deps(), {
|
||||
playlist: PLAYLIST,
|
||||
selectedContentType: 'vod',
|
||||
cmd: '/media/file_42.mpg',
|
||||
linkFlags: { use_http_tmp_link: '0' },
|
||||
});
|
||||
|
||||
expect(streamUrl).toBe(
|
||||
'http://demo.example/stalker_portal/media/video_77.mpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a localized expire date string from account info', async () => {
|
||||
const expireDate = 1_713_139_200;
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
|
||||
+60
-98
@@ -1,5 +1,6 @@
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
isStalkerStreamCredentialSafe,
|
||||
PlaylistMeta,
|
||||
StalkerPortalActions,
|
||||
StalkerPortalItem,
|
||||
@@ -8,6 +9,12 @@ import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import { StalkerContentType } from '../stalker-store.contracts';
|
||||
import {
|
||||
resolveStalkerStaticPlaybackUrl,
|
||||
type StalkerLinkFlagSource,
|
||||
} from './stalker-link-semantics.utils';
|
||||
import { resolveStalkerPlaybackUrl } from './stalker-playback-command.utils';
|
||||
import {
|
||||
ensureStalkerSession,
|
||||
executeStalkerRequest,
|
||||
type StalkerPortalRepairApi,
|
||||
} from './stalker-request.utils';
|
||||
@@ -29,108 +36,13 @@ export interface StalkerPlayerRequestDeps {
|
||||
portalRepair?: StalkerPortalRepairApi;
|
||||
}
|
||||
|
||||
export interface StalkerPlayableItemLike extends StalkerPortalItem {
|
||||
export interface StalkerPlayableItemLike
|
||||
extends StalkerPortalItem,
|
||||
StalkerLinkFlagSource {
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
}
|
||||
|
||||
export function normalizeStalkerPlaybackCommand(value: string): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const splitAt = trimmed.indexOf(' ');
|
||||
if (splitAt > 0) {
|
||||
const candidate = trimmed.slice(splitAt + 1).trim();
|
||||
if (
|
||||
candidate.startsWith('http://') ||
|
||||
candidate.startsWith('https://') ||
|
||||
candidate.startsWith('/') ||
|
||||
candidate.startsWith('?')
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveStalkerPlaybackUrl(
|
||||
portalUrl: string,
|
||||
originalCmd: string,
|
||||
responseCmd: string
|
||||
): string {
|
||||
const url = normalizeStalkerPlaybackCommand(responseCmd);
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const portalUrlObj = new URL(portalUrl);
|
||||
// The installation base is the endpoint path MINUS the API suffix
|
||||
// discovery appended (`/portal.php`, `/server/load.php`) — endpoint
|
||||
// discovery can persist arbitrary nested installations
|
||||
// (`/cp/server/load.php`), so a fixed segment allowlist would
|
||||
// resolve `/media/...` against the wrong root. The legacy marker
|
||||
// segments stay as the fallback for URLs that carry neither suffix.
|
||||
const endpointPath = portalUrlObj.pathname;
|
||||
let basePath = '';
|
||||
const apiSuffix = /\/(?:portal\.php|server\/load\.php|[^/]*\.php)$/i;
|
||||
if (apiSuffix.test(endpointPath)) {
|
||||
basePath = endpointPath.replace(apiSuffix, '');
|
||||
} else {
|
||||
const pathParts = endpointPath.split('/');
|
||||
for (let index = 0; index < pathParts.length; index += 1) {
|
||||
if (
|
||||
pathParts[index] === 'stalker_portal' ||
|
||||
pathParts[index] === 'c' ||
|
||||
pathParts[index] === 'portal'
|
||||
) {
|
||||
basePath = '/' + pathParts.slice(1, index + 1).join('/');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (url.startsWith('?')) {
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(originalCmd);
|
||||
if (
|
||||
normalizedCmd.startsWith('http://') ||
|
||||
normalizedCmd.startsWith('https://')
|
||||
) {
|
||||
return `${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
return `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
|
||||
}
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
return `${portalUrlObj.origin}${basePath}${url}`;
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function shouldResolveMovieFileId(
|
||||
item: Pick<StalkerPlayableItemLike, 'has_files'> | null | undefined,
|
||||
cmd: string
|
||||
): boolean {
|
||||
return (
|
||||
item?.has_files !== undefined &&
|
||||
!cmd.includes('://') &&
|
||||
cmd.includes('/media/') &&
|
||||
!cmd.includes('/media/file_')
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchStalkerPlaybackLink(
|
||||
deps: StalkerPlayerRequestDeps,
|
||||
options: {
|
||||
@@ -139,8 +51,58 @@ export async function fetchStalkerPlaybackLink(
|
||||
cmd: string;
|
||||
series?: number;
|
||||
forcedContentType?: StalkerContentType;
|
||||
/**
|
||||
* The catalog row this `cmd` came from. Without it every playback
|
||||
* mints a temporary link; with it, rows that set neither
|
||||
* `use_http_tmp_link` nor `use_load_balancing` play their static
|
||||
* `cmd` and never touch the portal.
|
||||
*/
|
||||
linkFlags?: StalkerLinkFlagSource | null;
|
||||
}
|
||||
): Promise<string> {
|
||||
// An episode is selected server-side by the `series` parameter, so a
|
||||
// series request has no static answer even when the parent row is
|
||||
// unflagged — the static `cmd` addresses the series, not the episode.
|
||||
if (options.series === undefined) {
|
||||
const staticUrl = resolveStalkerStaticPlaybackUrl(
|
||||
options.linkFlags,
|
||||
options.cmd
|
||||
);
|
||||
if (staticUrl) {
|
||||
// Returning here skips the request that used to authenticate.
|
||||
// Not every caller has a warm session: the global collection
|
||||
// detail sets the playlist and the item straight from a persisted
|
||||
// row, with no catalog load in between, so a VOD opened from
|
||||
// Favorites on a cold start would play a same-host gated stream
|
||||
// without a Bearer token. Warming at this single choke point
|
||||
// covers ITV, VOD, radio and downloads alike.
|
||||
// Classify BEFORE authenticating. A stream on a foreign host never
|
||||
// needs the portal session, and warming it anyway would block
|
||||
// playback behind a handshake worth up to 15 s per request against
|
||||
// a slow or offline portal (`stalker.events.ts`) for a result that
|
||||
// is then discarded — the CDN is reachable even when the portal is
|
||||
// not.
|
||||
if (
|
||||
!isStalkerStreamCredentialSafe(
|
||||
options.playlist.portalUrl ?? '',
|
||||
staticUrl
|
||||
)
|
||||
) {
|
||||
return staticUrl;
|
||||
}
|
||||
|
||||
// Portal-owned: it may be gated on the Bearer token, and minting
|
||||
// the link used to be what established the session. Without a
|
||||
// usable one, serving this would be serving a known 401 — fall
|
||||
// back to the request path instead, which both mints a URL that
|
||||
// carries its own token and is the only path that can observe a
|
||||
// failure and trigger the lazy portal repair.
|
||||
if (await ensureStalkerSession(deps, options.playlist)) {
|
||||
return staticUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentType =
|
||||
options.forcedContentType ?? options.selectedContentType;
|
||||
const response = await executeStalkerRequest<StalkerPlayerResponse>(
|
||||
|
||||
@@ -38,6 +38,64 @@ export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist {
|
||||
} as Playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the portal session WITHOUT issuing a content request.
|
||||
*
|
||||
* Needed wherever playback returns a URL without calling the portal: minting
|
||||
* a link used to be what authenticated, and tokens live in memory only
|
||||
* (`StalkerSessionService.tokenCache`), so a cold start would otherwise hand
|
||||
* a same-host gated stream headers with no `Authorization`. `ensureToken`
|
||||
* also validates the identity the cached token was negotiated for, which the
|
||||
* raw `getCachedToken()` the header builders use cannot.
|
||||
*
|
||||
* Cheap where it is not needed: a simple portal returns immediately, and a
|
||||
* warm cache with a matching fingerprint resolves without a request.
|
||||
*
|
||||
* Best-effort on purpose — a static URL may point at a CDN that needs no
|
||||
* credentials, so a failed handshake must not cost the user their playback.
|
||||
*
|
||||
* Returns whether the session is good enough to serve a stream that needs
|
||||
* portal credentials: `true` for a portal that needs no token at all and for
|
||||
* one that has a usable token, `false` for a full portal left without one.
|
||||
* Callers use it to decide whether a portal-owned static URL can be trusted or
|
||||
* whether they should fall back to the request path — which is also the path
|
||||
* that can observe a failure and trigger the lazy repair.
|
||||
*/
|
||||
export async function ensureStalkerSession(
|
||||
deps: StalkerRequestDeps,
|
||||
playlist: PlaylistMeta | undefined,
|
||||
logger?: { warn(...args: unknown[]): void }
|
||||
): Promise<boolean> {
|
||||
if (!playlist) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The repair override is applied here for the same reason
|
||||
// `executeStalkerRequest` applies it on its first line: a completed repair
|
||||
// may have moved the endpoint or the mode while the caller still holds the
|
||||
// pre-repair row, and handshaking against the configuration a repair has
|
||||
// already proven broken would strand the session.
|
||||
const effective = deps.portalRepair
|
||||
? deps.portalRepair.applyOverride(playlist)
|
||||
: playlist;
|
||||
|
||||
// A token-free panel needs no session, so it can serve credentialed
|
||||
// streams as well as it ever could.
|
||||
if (!isFullStalkerPortalPlaylist(effective)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const { token } = await deps.stalkerSession.ensureToken(
|
||||
toStalkerSessionPlaylist(effective)
|
||||
);
|
||||
return Boolean(token);
|
||||
} catch (error) {
|
||||
logger?.warn('Could not establish the Stalker session', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes one Stalker request according to the playlist's portal mode:
|
||||
* full portals go through the authenticated session (handshake + Bearer
|
||||
|
||||
+8
-2
@@ -262,8 +262,14 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
playlist: this.catalog.playlist(),
|
||||
downloadsService: this.downloadsService,
|
||||
fetchMovieFileId: (id) => this.catalog.fetchMovieFileId(id),
|
||||
fetchLinkToPlay: (portalUrl, macAddress, cmd) =>
|
||||
this.catalog.fetchLinkToPlay(portalUrl, macAddress, cmd),
|
||||
fetchLinkToPlay: (portalUrl, macAddress, cmd, linkFlags) =>
|
||||
this.catalog.fetchLinkToPlay(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
cmd,
|
||||
undefined,
|
||||
linkFlags
|
||||
),
|
||||
language:
|
||||
this.translateService.currentLang ||
|
||||
this.translateService.defaultLang ||
|
||||
|
||||
+102
@@ -120,4 +120,106 @@ describe('startStalkerVodDownload', () => {
|
||||
expect(snapshot).not.toHaveProperty('rating');
|
||||
expect(snapshot).not.toHaveProperty('tmdbId');
|
||||
});
|
||||
|
||||
it('hands the movie row on so an unflagged CDN movie yields a permanent URL', async () => {
|
||||
// The download row stores whatever URL comes back and retry replays
|
||||
// it, so a row that needs no temporary link must be recognised as
|
||||
// such — a 5 s link would survive only the first attempt.
|
||||
const startDownload = jest.fn().mockResolvedValue({ success: true });
|
||||
const fetchLinkToPlay = jest
|
||||
.fn()
|
||||
.mockResolvedValue('https://cdn.example.test/movie.mpg');
|
||||
const data = {
|
||||
id: '42',
|
||||
cmd: 'ffrt3 https://cdn.example.test/movie.mpg',
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
info: { name: 'Static Movie' },
|
||||
};
|
||||
|
||||
await startStalkerVodDownload(
|
||||
{
|
||||
type: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
cmd: data.cmd,
|
||||
data,
|
||||
} as unknown as VodDetailsItem,
|
||||
{
|
||||
playlist: {
|
||||
id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.test',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
},
|
||||
downloadsService: { startDownload },
|
||||
fetchMovieFileId: jest.fn(),
|
||||
fetchLinkToPlay,
|
||||
}
|
||||
);
|
||||
|
||||
expect(fetchLinkToPlay).toHaveBeenCalledWith(
|
||||
'https://stalker.example.test',
|
||||
'00:1A:79:12:34:56',
|
||||
data.cmd,
|
||||
expect.objectContaining({
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
})
|
||||
);
|
||||
expect(startDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://cdn.example.test/movie.mpg',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps minting a link for an unflagged movie on the portal host', async () => {
|
||||
// A download cannot carry portal credentials — the main-process
|
||||
// stored-header allowlist is User-Agent/Origin/Referer only. So a
|
||||
// same-host static URL, which the portal may gate on the mac cookie
|
||||
// or Bearer token, must still go through create_link and use the
|
||||
// minted URL's own access token.
|
||||
const startDownload = jest.fn().mockResolvedValue({ success: true });
|
||||
const fetchLinkToPlay = jest
|
||||
.fn()
|
||||
.mockResolvedValue('https://stalker.example.test/tmp/42?tok=1');
|
||||
const data = {
|
||||
id: '42',
|
||||
cmd: 'ffrt3 https://stalker.example.test/movies/42.mkv',
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
info: { name: 'Same Host Movie' },
|
||||
};
|
||||
|
||||
await startStalkerVodDownload(
|
||||
{
|
||||
type: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
cmd: data.cmd,
|
||||
data,
|
||||
} as unknown as VodDetailsItem,
|
||||
{
|
||||
playlist: {
|
||||
id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.test/portal.php',
|
||||
macAddress: '00:1A:79:12:34:56',
|
||||
},
|
||||
downloadsService: { startDownload },
|
||||
fetchMovieFileId: jest.fn(),
|
||||
fetchLinkToPlay,
|
||||
}
|
||||
);
|
||||
|
||||
// No row handed over ⇒ the static shortcut is not offered.
|
||||
expect(fetchLinkToPlay).toHaveBeenCalledWith(
|
||||
'https://stalker.example.test/portal.php',
|
||||
'00:1A:79:12:34:56',
|
||||
data.cmd,
|
||||
undefined
|
||||
);
|
||||
expect(startDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://stalker.example.test/tmp/42?tok=1',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,10 @@ type StalkerVodDetailsItem = Extract<VodDetailsItem, { type: 'stalker' }>;
|
||||
import {
|
||||
normalizeStalkerEntityId,
|
||||
normalizeStalkerEntityIdAsNumber,
|
||||
resolveStalkerStaticPlaybackUrl,
|
||||
type StalkerLinkFlagSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { isStalkerStreamCredentialSafe } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Starting a download of a Stalker VOD item.
|
||||
@@ -25,6 +28,9 @@ import {
|
||||
export interface DownloadVodData {
|
||||
id?: string | number;
|
||||
has_files?: unknown;
|
||||
/** Temporary-link flags — see {@link StalkerLinkFlagSource}. */
|
||||
use_http_tmp_link?: unknown;
|
||||
use_load_balancing?: unknown;
|
||||
title?: string;
|
||||
category_id?: string | number;
|
||||
info?: {
|
||||
@@ -61,7 +67,8 @@ export interface StalkerVodDownloadDeps {
|
||||
fetchLinkToPlay: (
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string
|
||||
cmd: string,
|
||||
linkFlags?: StalkerLinkFlagSource | null
|
||||
) => Promise<string | null>;
|
||||
language?: string;
|
||||
}
|
||||
@@ -124,10 +131,30 @@ export async function startStalkerVodDownload(
|
||||
firstText(itemData?.info?.name, itemData?.title) ?? 'Unknown';
|
||||
const cmdToUse = await resolveDownloadCmd(item, itemData, deps);
|
||||
|
||||
// A movie whose row needs no temporary link yields a permanent URL, which
|
||||
// is what the download row stores — a 5 s link would only ever survive the
|
||||
// first attempt.
|
||||
//
|
||||
// But a download cannot authenticate the way playback can: the stored
|
||||
// header allowlist in the main process is User-Agent / Origin / Referer
|
||||
// only (`download-request-headers.ts`), with no Cookie or Authorization.
|
||||
// So the static shortcut is offered only for a URL that needs no portal
|
||||
// credentials — i.e. one the shared classifier says is NOT portal-owned.
|
||||
// A same-host movie keeps going through `create_link`, whose minted URL
|
||||
// carries its own access token.
|
||||
const staticCandidate = resolveStalkerStaticPlaybackUrl(
|
||||
itemData,
|
||||
cmdToUse
|
||||
);
|
||||
const staticUrlIsSelfAuthenticating =
|
||||
staticCandidate !== null &&
|
||||
!isStalkerStreamCredentialSafe(playlist.portalUrl, staticCandidate);
|
||||
|
||||
const url = await deps.fetchLinkToPlay(
|
||||
playlist.portalUrl,
|
||||
playlist.macAddress,
|
||||
cmdToUse
|
||||
cmdToUse,
|
||||
staticUrlIsSelfAuthenticating ? itemData : undefined
|
||||
);
|
||||
if (!url) {
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
buildStalkerSelectedVodItem,
|
||||
isStalkerSeriesFlag,
|
||||
StalkerLinkFlagSource,
|
||||
StalkerStore,
|
||||
StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
@@ -251,9 +252,17 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
|
||||
async fetchLinkToPlay(
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string
|
||||
cmd: string,
|
||||
series?: number,
|
||||
linkFlags?: StalkerLinkFlagSource | null
|
||||
): Promise<string> {
|
||||
return this.stalkerStore.fetchLinkToPlay(portalUrl, macAddress, cmd);
|
||||
return this.stalkerStore.fetchLinkToPlay(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
cmd,
|
||||
series,
|
||||
linkFlags
|
||||
);
|
||||
}
|
||||
|
||||
resolveVodPlayback(
|
||||
|
||||
@@ -441,6 +441,115 @@ describe('PlaylistsService', () => {
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
it('carries the Stalker session fields through the SQLite fallback read', async () => {
|
||||
// Without these, every cold Electron session loses the persisted
|
||||
// cadence AND the identity the token was negotiated for — so the
|
||||
// mismatch check cannot run and the watchdog falls back to default.
|
||||
const electron = {
|
||||
dbGetAppPlaylist: jest.fn(async () => ({
|
||||
id: 'stalker-1',
|
||||
stalkerToken: 'TOKEN',
|
||||
stalkerSessionIdentity: 'fingerprint-1',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 7,
|
||||
})),
|
||||
dbGetAppPlaylists: jest.fn(async () => []),
|
||||
dbGetAppState: jest.fn(async () => '1'),
|
||||
dbSetAppState: jest.fn(),
|
||||
dbUpsertAppPlaylist: jest.fn(),
|
||||
dbUpsertAppPlaylists: jest.fn(),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
|
||||
const service = createService();
|
||||
const playlist = await firstValueFrom(
|
||||
service.getPlaylistById('stalker-1')
|
||||
);
|
||||
|
||||
expect(playlist).toEqual(
|
||||
expect.objectContaining({
|
||||
stalkerToken: 'TOKEN',
|
||||
stalkerSessionIdentity: 'fingerprint-1',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 7,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('clears a stale watchdog cadence when the portal stops advertising one', async () => {
|
||||
// Reusing the token skips the profile that would correct the cadence,
|
||||
// so leaving the old value on the row means the next restart applies
|
||||
// a cadence the portal no longer asks for.
|
||||
const existingPlaylist: Playlist = {
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker',
|
||||
count: 0,
|
||||
importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(),
|
||||
lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(),
|
||||
autoRefresh: false,
|
||||
stalkerToken: 'OLD',
|
||||
stalkerWatchdogTimeout: 45,
|
||||
stalkerTimeslot: 7,
|
||||
} as Playlist;
|
||||
const dbService = {
|
||||
getAll: jest.fn(() => of([])),
|
||||
getByID: jest.fn(() => of(existingPlaylist)),
|
||||
update: jest.fn((_storeName: string, playlist: Playlist) =>
|
||||
of(playlist)
|
||||
),
|
||||
};
|
||||
testWindow.electron = undefined;
|
||||
|
||||
const service = createService(dbService);
|
||||
|
||||
await firstValueFrom(
|
||||
service.updateStalkerSession('stalker-1', { stalkerToken: 'NEW' })
|
||||
);
|
||||
|
||||
const written = dbService.update.mock.calls[0][1] as Playlist;
|
||||
expect(written.stalkerToken).toBe('NEW');
|
||||
expect(written.stalkerWatchdogTimeout).toBeUndefined();
|
||||
expect(written.stalkerTimeslot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists the watchdog cadence alongside the stalker token', async () => {
|
||||
const existingPlaylist: Playlist = {
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker',
|
||||
count: 0,
|
||||
importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(),
|
||||
lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(),
|
||||
autoRefresh: false,
|
||||
} as Playlist;
|
||||
const dbService = {
|
||||
getAll: jest.fn(() => of([])),
|
||||
getByID: jest.fn(() => of(existingPlaylist)),
|
||||
update: jest.fn((_storeName: string, playlist: Playlist) =>
|
||||
of(playlist)
|
||||
),
|
||||
};
|
||||
testWindow.electron = undefined;
|
||||
|
||||
const service = createService(dbService);
|
||||
|
||||
await firstValueFrom(
|
||||
service.updateStalkerSession('stalker-1', {
|
||||
stalkerToken: 'TOKEN',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 12,
|
||||
})
|
||||
);
|
||||
|
||||
expect(dbService.update).toHaveBeenCalledWith(
|
||||
DbStores.Playlists,
|
||||
expect.objectContaining({
|
||||
stalkerToken: 'TOKEN',
|
||||
stalkerWatchdogTimeout: 90,
|
||||
stalkerTimeslot: 12,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('persists hiddenGroupTitles in playlist meta updates', async () => {
|
||||
const existingPlaylist: Playlist = {
|
||||
_id: 'playlist-1',
|
||||
|
||||
@@ -242,6 +242,13 @@ export class PlaylistsService {
|
||||
stalkerSignature2: playlist.stalkerSignature2,
|
||||
isFullStalkerPortal: playlist.isFullStalkerPortal,
|
||||
stalkerToken: playlist.stalkerToken,
|
||||
// Without these the Electron cold read loses the persisted
|
||||
// cadence AND the identity the token was negotiated for, so the
|
||||
// mismatch check cannot run and the watchdog falls back to the
|
||||
// default.
|
||||
stalkerSessionIdentity: playlist.stalkerSessionIdentity,
|
||||
stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout,
|
||||
stalkerTimeslot: playlist.stalkerTimeslot,
|
||||
stalkerAccountInfo: playlist.stalkerAccountInfo,
|
||||
} as Playlist;
|
||||
}
|
||||
@@ -729,6 +736,45 @@ export class PlaylistsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a freshly negotiated Stalker session on the playlist so the
|
||||
* next app start can re-present the token (the portal handshake is
|
||||
* idempotent) and keep the portal's own watchdog cadence — reusing a
|
||||
* token skips the `get_profile` that carries the cadence, so it has to
|
||||
* survive with the token. No-ops when the playlist row does not exist
|
||||
* yet: the import flow saves both with the playlist itself.
|
||||
*/
|
||||
updateStalkerSession(
|
||||
playlistId: string,
|
||||
session: {
|
||||
stalkerToken: string;
|
||||
stalkerSessionIdentity?: string;
|
||||
stalkerWatchdogTimeout?: number;
|
||||
stalkerTimeslot?: number;
|
||||
}
|
||||
) {
|
||||
return this.serializePlaylistWrite(playlistId, async () => {
|
||||
const playlist = await firstValueFrom(
|
||||
this.getPlaylistById(playlistId)
|
||||
);
|
||||
if (!playlist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The cadence is written unconditionally, including as
|
||||
// `undefined`: a portal that stops advertising one must not leave
|
||||
// a stale value behind for the next restart to re-apply, since
|
||||
// reusing the token skips the profile that would correct it.
|
||||
return this.persistPlaylistMutation({
|
||||
...playlist,
|
||||
stalkerToken: session.stalkerToken,
|
||||
stalkerSessionIdentity: session.stalkerSessionIdentity,
|
||||
stalkerWatchdogTimeout: session.stalkerWatchdogTimeout,
|
||||
stalkerTimeslot: session.stalkerTimeslot,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateFavorites(id: string, favorites: string[]) {
|
||||
return this.serializePlaylistWrite(id, async () => {
|
||||
const playlist = await firstValueFrom(this.getPlaylistById(id));
|
||||
|
||||
@@ -58,6 +58,23 @@ export interface Playlist {
|
||||
serverTimezone?: string;
|
||||
/** Session token for full stalker portal authentication - persisted for session */
|
||||
stalkerToken?: string;
|
||||
/**
|
||||
* Identity fingerprint the persisted `stalkerToken` was negotiated for.
|
||||
* Re-presenting a token minted for a DIFFERENT identity (the user edited
|
||||
* the MAC, serial or device ids) would pair a new identity with an old
|
||||
* session — the same class of bug the in-memory cache guards against, so
|
||||
* reuse is refused unless this still matches the playlist.
|
||||
*/
|
||||
stalkerSessionIdentity?: string;
|
||||
/**
|
||||
* Watchdog cadence the portal advertised in `get_profile`, persisted
|
||||
* alongside the token: reusing a stored token skips the profile request
|
||||
* that carries these, so without persistence the keep-alive would fall
|
||||
* back to the 120 s default forever.
|
||||
*/
|
||||
stalkerWatchdogTimeout?: number;
|
||||
/** Per-user watchdog jitter (seconds) from `get_profile`. */
|
||||
stalkerTimeslot?: number;
|
||||
/** Serial number for stalker portal - generated once and stored for consistency */
|
||||
stalkerSerialNumber?: string;
|
||||
/** Optional device ID 1 for stalker portal - if not provided, auto-generated from MAC */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
classifyStalkerAuthFailureBody,
|
||||
createStalkerAuthFailureMarker,
|
||||
extractStalkerAuthFailureBody,
|
||||
isStalkerAuthFailureBody,
|
||||
isStalkerAuthFailureMessage,
|
||||
isStalkerAuthFailureResponse,
|
||||
@@ -98,6 +100,58 @@ describe('isStalkerAuthFailureResponse', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('transport marker', () => {
|
||||
it('round-trips through create / detect / extract', () => {
|
||||
const marker = createStalkerAuthFailureMarker('Access denied.');
|
||||
expect(isStalkerAuthFailureResponse(marker)).toBe(true);
|
||||
expect(extractStalkerAuthFailureBody(marker)).toBe('Access denied.');
|
||||
});
|
||||
|
||||
it('is what keeps the repair trigger working once Electron classifies', () => {
|
||||
// The main process replaces the plain-text body with this marker, so
|
||||
// a detector that only knew the raw string would silently stop
|
||||
// triggering portal repair on the desktop app.
|
||||
expect(
|
||||
isStalkerAuthFailureResponse({
|
||||
stalkerAuthFailure: 'Authorization failed.',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not accept arbitrary objects as markers', () => {
|
||||
expect(isStalkerAuthFailureResponse({ stalkerAuthFailure: 'nope' })).toBe(
|
||||
false
|
||||
);
|
||||
expect(extractStalkerAuthFailureBody({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractStalkerAuthFailureBody', () => {
|
||||
it('extracts from the raw PWA payload string', () => {
|
||||
expect(extractStalkerAuthFailureBody('Authorization failed. 12')).toBe(
|
||||
'Authorization failed.'
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts from a {js: body} envelope', () => {
|
||||
expect(
|
||||
extractStalkerAuthFailureBody({ js: 'Authorization failed. 75' })
|
||||
).toBe('Authorization failed.');
|
||||
});
|
||||
|
||||
it('reports no canonical body for the structured panel wording', () => {
|
||||
// Still an auth failure — just not one of the middleware's bodies.
|
||||
const response = { js: { error: 'Invalid token' } };
|
||||
expect(extractStalkerAuthFailureBody(response)).toBeNull();
|
||||
expect(isStalkerAuthFailureResponse(response)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null for ordinary responses', () => {
|
||||
expect(extractStalkerAuthFailureBody({ js: { data: [] } })).toBeNull();
|
||||
expect(extractStalkerAuthFailureBody(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStalkerAuthFailureMessage', () => {
|
||||
it('accepts messages our own auth layer produces', () => {
|
||||
expect(
|
||||
|
||||
@@ -128,17 +128,77 @@ export function isStalkerAuthFailureMessage(message: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a portal response is an authorization failure in EITHER wire
|
||||
* shape: the middleware's plain-text body, or the JSON envelope some panels
|
||||
* answer instead. Classification and the lazy-repair trigger must use this,
|
||||
* not the string-only primitive: a JSON-failing panel would otherwise be
|
||||
* persisted as token-free and never repaired.
|
||||
* Structured shape a transport returns in place of the raw plain-text body.
|
||||
*
|
||||
* Returned, never thrown: `ipcRenderer.invoke` strips custom properties from
|
||||
* rejected values, so a classified body would not survive the trip to the
|
||||
* renderer as an error. The Electron main process mints this because it is
|
||||
* where the body arrives; the PWA proxy still delivers the raw string, and
|
||||
* every consumer goes through the predicates below rather than caring which.
|
||||
*/
|
||||
export interface StalkerAuthFailureMarker {
|
||||
stalkerAuthFailure: StalkerAuthFailureBody;
|
||||
}
|
||||
|
||||
export function createStalkerAuthFailureMarker(
|
||||
body: StalkerAuthFailureBody
|
||||
): StalkerAuthFailureMarker {
|
||||
return { stalkerAuthFailure: body };
|
||||
}
|
||||
|
||||
function readAuthFailureMarker(
|
||||
value: unknown
|
||||
): StalkerAuthFailureBody | null {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return classifyStalkerAuthFailureBody(
|
||||
(value as StalkerAuthFailureMarker).stalkerAuthFailure
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the canonical failure body from any shape an auth failure reaches
|
||||
* a consumer in: the transport marker (Electron), the raw plain-text payload
|
||||
* (PWA proxy), or a `{js: '<body>'}` envelope. Returns null for everything
|
||||
* else, including the structured `{js: {error|msg}}` form — that one is a
|
||||
* panel's own wording, not one of the middleware's three bodies, so it has
|
||||
* no canonical body to report. Use `isStalkerAuthFailureResponse` when the
|
||||
* question is merely "did authorization fail?".
|
||||
*/
|
||||
export function extractStalkerAuthFailureBody(
|
||||
value: unknown
|
||||
): StalkerAuthFailureBody | null {
|
||||
return (
|
||||
readAuthFailureMarker(value) ??
|
||||
classifyStalkerAuthFailureBody(value) ??
|
||||
classifyStalkerAuthFailureBody(
|
||||
typeof value === 'object' && value !== null && 'js' in value
|
||||
? (value as { js?: unknown }).js
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a portal response is an authorization failure in ANY wire shape:
|
||||
* the middleware's plain-text body, the transport marker minted from it, or
|
||||
* the JSON envelope some panels answer instead. Classification and the
|
||||
* lazy-repair trigger must use this, not the string-only primitive: a
|
||||
* JSON-failing panel would otherwise be persisted as token-free and never
|
||||
* repaired, and an Electron-classified body would stop triggering repair at
|
||||
* all.
|
||||
*/
|
||||
export function isStalkerAuthFailureResponse(response: unknown): boolean {
|
||||
if (isStalkerAuthFailureBody(response)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (readAuthFailureMarker(response) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
response === null ||
|
||||
typeof response !== 'object' ||
|
||||
|
||||
@@ -65,6 +65,33 @@ describe('isStalkerStreamCredentialSafe', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a terminal DNS root dot as the same host', () => {
|
||||
// `portal.example.` and `portal.example` resolve identically, but
|
||||
// `URL` preserves the dot — comparing literally classified a
|
||||
// portal-owned stream as third-party and stripped its credentials.
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://portal.example./live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
'http://portal.example./portal.php',
|
||||
'http://portal.example/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('still rejects a different host that merely shares a suffix', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://evil.portal.example./live/ch1.ts'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on missing or unparseable URLs', () => {
|
||||
expect(isStalkerStreamCredentialSafe(PORTAL, undefined)).toBe(false);
|
||||
expect(isStalkerStreamCredentialSafe(PORTAL, '')).toBe(false);
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
* credentials to a third party, and an https→http downgrade would replay a
|
||||
* TLS-obtained session in cleartext — both stay credential-free.
|
||||
*/
|
||||
/**
|
||||
* A terminal dot is the DNS root: `portal.example.` and `portal.example`
|
||||
* resolve to the same host, but `URL` preserves the dot, so a literal
|
||||
* comparison would call them different origins — classifying a portal-owned
|
||||
* stream as third-party and stripping the credentials it needs.
|
||||
*/
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.toLowerCase().replace(/\.$/, '');
|
||||
}
|
||||
|
||||
export function isStalkerStreamCredentialSafe(
|
||||
portalUrl: string | undefined | null,
|
||||
streamUrl: string | undefined | null
|
||||
@@ -36,7 +46,10 @@ export function isStalkerStreamCredentialSafe(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (portal.hostname.toLowerCase() !== stream.hostname.toLowerCase()) {
|
||||
if (
|
||||
normalizeHostname(portal.hostname) !==
|
||||
normalizeHostname(stream.hostname)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -157,9 +157,7 @@
|
||||
} @else if (isStalkerCategoryFailed()) {
|
||||
<app-workspace-context-error-view
|
||||
[title]="'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR' | translate"
|
||||
[description]="
|
||||
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT' | translate
|
||||
"
|
||||
[description]="stalkerCategoryErrorDescription()"
|
||||
[showActionButtons]="false"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
+26
-1
@@ -17,7 +17,10 @@ import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
StalkerStore,
|
||||
asStalkerPortalError,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
PortalCategorySortMode,
|
||||
persistPortalCategorySortMode,
|
||||
@@ -152,6 +155,28 @@ export class WorkspaceContextPanelComponent {
|
||||
this.stalkerStore.isCategoryResourceLoading;
|
||||
readonly isStalkerCategoryFailed =
|
||||
this.stalkerStore.isCategoryResourceFailed;
|
||||
/**
|
||||
* When category loading failed because the portal refused the session,
|
||||
* the portal's own explanation (msg/block_msg or the documented
|
||||
* plain-text failure body) replaces the generic hint; a login-required
|
||||
* refusal gets its own actionable text.
|
||||
*/
|
||||
readonly stalkerCategoryErrorDescription = computed(() => {
|
||||
const portalError = asStalkerPortalError(
|
||||
this.isStalkerCategoryFailed()
|
||||
);
|
||||
if (portalError?.portalText) {
|
||||
return portalError.portalText;
|
||||
}
|
||||
if (portalError?.kind === 'login-required') {
|
||||
return this.translate.instant(
|
||||
'PORTALS.ERROR_VIEW.STALKER_LOGIN_REQUIRED'
|
||||
);
|
||||
}
|
||||
return this.translate.instant(
|
||||
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT'
|
||||
);
|
||||
});
|
||||
// Category count badges are only available for Stalker Live TV, where the
|
||||
// full channel list is cached — VOD/series/radio still page lazily, so
|
||||
// their per-category totals are unknown.
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@
|
||||
"@ngrx/store-devtools": "21.1.1",
|
||||
"@ngx-pwa/local-storage": "21.0.0",
|
||||
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
|
||||
"angularx-qrcode": "21.0.4",
|
||||
"angularx-qrcode": "21.0.5",
|
||||
"artplayer": "5.4.0",
|
||||
"axios": "1.18.1",
|
||||
"better-sqlite3": "12.9.0",
|
||||
|
||||
Generated
+102
-16
@@ -95,8 +95,8 @@ importers:
|
||||
specifier: 0.0.1
|
||||
version: 0.0.1
|
||||
angularx-qrcode:
|
||||
specifier: 21.0.4
|
||||
version: 21.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))
|
||||
specifier: 21.0.5
|
||||
version: 21.0.5(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))
|
||||
artplayer:
|
||||
specifier: 5.4.0
|
||||
version: 5.4.0
|
||||
@@ -5104,8 +5104,8 @@ packages:
|
||||
typescript: '*'
|
||||
typescript-eslint: ^8.0.0
|
||||
|
||||
angularx-qrcode@21.0.4:
|
||||
resolution: {integrity: sha512-GZFa/X/3rHx/4peA4zNROkK6UaYqxJX0dgkEMk7dCcoYNwJM8/UkOkEUfcx+Btjr7iT4UEhf9twWhFjFp58wfw==}
|
||||
angularx-qrcode@21.0.5:
|
||||
resolution: {integrity: sha512-9Kv/Mi5tKzAkBL2xhYZyPaJEoq+b24mIpUAntp16kIBiozVnpGI6DbigTPJiSDkV7EpYHdjBJNGj8igFAHEp6g==}
|
||||
peerDependencies:
|
||||
'@angular/common': ^21.0.0
|
||||
'@angular/core': ^21.0.0
|
||||
@@ -5347,6 +5347,10 @@ packages:
|
||||
resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
balanced-match@4.0.4:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
base-64@1.0.0:
|
||||
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
|
||||
|
||||
@@ -6921,6 +6925,10 @@ packages:
|
||||
resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@11.4.0:
|
||||
resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@7.0.1:
|
||||
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
@@ -7330,6 +7338,10 @@ packages:
|
||||
resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.7.3:
|
||||
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
icss-utils@5.1.0:
|
||||
resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==}
|
||||
engines: {node: ^10 || ^12 || >= 14}
|
||||
@@ -7417,8 +7429,8 @@ packages:
|
||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ip-address@10.2.0:
|
||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||
ip-address@10.4.0:
|
||||
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
@@ -7976,6 +7988,9 @@ packages:
|
||||
jsonfile@6.2.0:
|
||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||
|
||||
jsonfile@6.2.1:
|
||||
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
||||
|
||||
jsonparse@1.3.1:
|
||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||
engines: {'0': node >= 0.2.0}
|
||||
@@ -8188,6 +8203,10 @@ packages:
|
||||
resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
make-fetch-happen@15.0.6:
|
||||
resolution: {integrity: sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
makeerror@1.0.12:
|
||||
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
|
||||
|
||||
@@ -8475,6 +8494,10 @@ packages:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@10.2.6:
|
||||
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
@@ -8508,6 +8531,10 @@ packages:
|
||||
resolution: {integrity: sha512-yHK8pb0iCGat0lDrs/D6RZmCdaBT64tULXjdxjSMAqoDi18Q3qKEUTHypHQZQd9+FYpIS+lkvpq6C/R6SbUeRw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
minipass-fetch@5.0.2:
|
||||
resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
minipass-flush@1.0.7:
|
||||
resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -10376,6 +10403,10 @@ packages:
|
||||
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -10684,6 +10715,7 @@ packages:
|
||||
tsconfck@3.1.6:
|
||||
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
|
||||
engines: {node: ^18 || >=20}
|
||||
deprecated: unmaintained
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^5.0.0
|
||||
@@ -13076,7 +13108,7 @@ snapshots:
|
||||
dependencies:
|
||||
cross-dirname: 0.1.0
|
||||
debug: 4.4.3
|
||||
fs-extra: 11.3.5
|
||||
fs-extra: 11.4.0
|
||||
minimist: 1.2.8
|
||||
postject: 1.0.0-alpha.6
|
||||
transitivePeerDependencies:
|
||||
@@ -13835,7 +13867,7 @@ snapshots:
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
string-width-cjs: string-width@4.2.3
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi: 7.2.0
|
||||
strip-ansi-cjs: strip-ansi@6.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
@@ -15968,7 +16000,7 @@ snapshots:
|
||||
'@tufjs/models@4.1.0':
|
||||
dependencies:
|
||||
'@tufjs/canonical-json': 2.0.0
|
||||
minimatch: 10.2.4
|
||||
minimatch: 10.2.6
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
dependencies:
|
||||
@@ -16662,7 +16694,7 @@ snapshots:
|
||||
- chokidar
|
||||
- supports-color
|
||||
|
||||
angularx-qrcode@21.0.4(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)):
|
||||
angularx-qrcode@21.0.5(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)):
|
||||
dependencies:
|
||||
'@angular/common': 21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)
|
||||
'@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.16.2)
|
||||
@@ -17081,6 +17113,8 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.3: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
base-64@1.0.0: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
@@ -17194,7 +17228,7 @@ snapshots:
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
dependencies:
|
||||
balanced-match: 4.0.3
|
||||
balanced-match: 4.0.4
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
@@ -18917,6 +18951,13 @@ snapshots:
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@11.4.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
optional: true
|
||||
|
||||
fs-extra@7.0.1:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -19511,6 +19552,11 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.7.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
optional: true
|
||||
|
||||
icss-utils@5.1.0(postcss@8.5.6):
|
||||
dependencies:
|
||||
postcss: 8.5.6
|
||||
@@ -19577,7 +19623,7 @@ snapshots:
|
||||
|
||||
ip-address@10.1.0: {}
|
||||
|
||||
ip-address@10.2.0: {}
|
||||
ip-address@10.4.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
@@ -20320,6 +20366,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
jsonfile@6.2.1:
|
||||
dependencies:
|
||||
universalify: 2.0.1
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
optional: true
|
||||
|
||||
jsonparse@1.3.1: {}
|
||||
|
||||
keycode@2.2.1: {}
|
||||
@@ -20591,6 +20644,23 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
make-fetch-happen@15.0.6:
|
||||
dependencies:
|
||||
'@gar/promise-retry': 1.0.3
|
||||
'@npmcli/agent': 4.0.0
|
||||
'@npmcli/redact': 4.0.0
|
||||
cacache: 20.0.3
|
||||
http-cache-semantics: 4.2.0
|
||||
minipass: 7.1.3
|
||||
minipass-fetch: 5.0.2
|
||||
minipass-flush: 1.0.7
|
||||
minipass-pipeline: 1.2.4
|
||||
negotiator: 1.0.0
|
||||
proc-log: 6.1.0
|
||||
ssri: 13.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
makeerror@1.0.12:
|
||||
dependencies:
|
||||
tmpl: 1.0.5
|
||||
@@ -21121,6 +21191,10 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
|
||||
minimatch@10.2.6:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
|
||||
minimatch@3.1.2:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.13
|
||||
@@ -21163,6 +21237,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
encoding: 0.1.13
|
||||
|
||||
minipass-fetch@5.0.2:
|
||||
dependencies:
|
||||
minipass: 7.1.3
|
||||
minipass-sized: 2.0.0
|
||||
minizlib: 3.1.0
|
||||
optionalDependencies:
|
||||
iconv-lite: 0.7.3
|
||||
|
||||
minipass-flush@1.0.7:
|
||||
dependencies:
|
||||
minipass: 3.3.6
|
||||
@@ -23262,7 +23344,7 @@ snapshots:
|
||||
|
||||
socks@2.8.9:
|
||||
dependencies:
|
||||
ip-address: 10.2.0
|
||||
ip-address: 10.4.0
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
sorted-array-functions@1.3.0: {}
|
||||
@@ -23380,7 +23462,7 @@ snapshots:
|
||||
dependencies:
|
||||
eastasianwidth: 0.2.0
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
@@ -23437,6 +23519,10 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
strip-bom@4.0.0: {}
|
||||
@@ -23807,7 +23893,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@tufjs/models': 4.1.0
|
||||
debug: 4.4.3
|
||||
make-fetch-happen: 15.0.3
|
||||
make-fetch-happen: 15.0.6
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -24448,7 +24534,7 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
dependencies:
|
||||
|
||||
@@ -38,7 +38,6 @@ export const maxLinesBaseline = [
|
||||
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts',
|
||||
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts',
|
||||
'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts',
|
||||
'libs/portal/stalker/data-access/src/lib/stalker-session.service.ts',
|
||||
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts',
|
||||
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts',
|
||||
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts',
|
||||
|
||||
Reference in new issue
Block a user