mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
Merge remote-tracking branch 'origin/master' into agent/download-season-queue
This commit is contained in:
commit
e977791126
49 files changed
+4765
-434
No files matched your search
@@ -0,0 +1,11 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
issues: [850, 686, 755]
|
||||
---
|
||||
|
||||
Stalker portals are no longer classified by their URL shape: importing probes
|
||||
the real API endpoint (`portal.php` vs `server/load.php`) and checks whether
|
||||
the portal actually requires authentication. Canonical Ministra URLs finally
|
||||
load content, `…/c` addresses resolve correctly, and misclassified existing
|
||||
portals repair themselves on first failure — keeping favorites and history.
|
||||
@@ -1043,7 +1043,7 @@ engine` (restart required) or
|
||||
|
||||
- Both portal types expose an account-info dialog through the same entry points: header playlist switcher (bottom section for the active playlist + per-row ⋮ menu), dashboard source card ⋮ menu, and the command palette. Gates use the shared predicates in `libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts`; `WorkspaceShellHeaderService.openAccountInfoFor()` picks the dialog by playlist type.
|
||||
- Xtream: `AccountInfoComponent` (`libs/portal/xtream/feature/src/lib/account-info/`), queries `get_account_info` live.
|
||||
- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes (full portals: handshake+`get_profile`; `portal.php`: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields). Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog").
|
||||
- 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.
|
||||
|
||||
**Favorites and Recently Viewed**:
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
addStalkerPortal,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSources,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
sourceRowByTitle,
|
||||
stalkerMockServer,
|
||||
test,
|
||||
waitForStalkerCatalog,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
/**
|
||||
* Endpoint discovery + behavior-based portal mode (issues #850, #686, #755).
|
||||
*
|
||||
* One persisted field — `isFullStalkerPortal` — decides whether the app
|
||||
* authenticates at all. It used to be a URL-shape guess frozen at import:
|
||||
* canonical `…/server/load.php` portals were persisted as token-free (every
|
||||
* request answered the plain-text `Authorization failed.`), and `…/c` URLs
|
||||
* were rewritten to a `portal.php` official Ministra never serves (404).
|
||||
*
|
||||
* The mock mirrors both worlds: `/portal.php` is a tolerant reseller panel,
|
||||
* `/server/load.php` enforces the Bearer token like real middleware, and the
|
||||
* `/ministra/*` prefix serves ONLY `server/load.php` — a genuine Ministra
|
||||
* host where `portal.php` 404s.
|
||||
*/
|
||||
|
||||
// Non-scenario MACs: every MAC gets deterministic auto-generated catalog
|
||||
// data, and per-MAC state cannot collide with the scenario MACs other spec
|
||||
// files rely on.
|
||||
const CANONICAL_IMPORT_MAC = '00:1A:79:00:00:21';
|
||||
const MINISTRA_IMPORT_MAC = '00:1A:79:00:00:22';
|
||||
const RESELLER_IMPORT_MAC = '00:1A:79:00:00:23';
|
||||
const REPAIR_FLAG_MAC = '00:1A:79:00:00:24';
|
||||
const REPAIR_ENDPOINT_MAC = '00:1A:79:00:00:25';
|
||||
const HEALTHY_RESELLER_MAC = '00:1A:79:00:00:26';
|
||||
|
||||
interface StoredPortalConfig {
|
||||
portalUrl: unknown;
|
||||
isFullStalkerPortal: unknown;
|
||||
}
|
||||
|
||||
type PlaylistStorageWindow = Window & {
|
||||
electron: {
|
||||
dbGetAppPlaylists: () => Promise<Record<string, unknown>[]>;
|
||||
dbUpsertAppPlaylist: (
|
||||
playlist: Record<string, unknown>
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
async function readStoredPortalConfig(
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<StoredPortalConfig | null> {
|
||||
return page.evaluate(async (playlistTitle) => {
|
||||
const electron = (window as unknown as PlaylistStorageWindow).electron;
|
||||
const playlists = await electron.dbGetAppPlaylists();
|
||||
const row = playlists.find(
|
||||
(playlist) => playlist['title'] === playlistTitle
|
||||
);
|
||||
return row
|
||||
? {
|
||||
portalUrl: row['portalUrl'],
|
||||
isFullStalkerPortal: row['isFullStalkerPortal'],
|
||||
}
|
||||
: null;
|
||||
}, title);
|
||||
}
|
||||
|
||||
async function seedStalkerPlaylist(
|
||||
page: Page,
|
||||
row: {
|
||||
id: string;
|
||||
title: string;
|
||||
macAddress: string;
|
||||
portalUrl: string;
|
||||
isFullStalkerPortal: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
await page.evaluate(async (playlist) => {
|
||||
const electron = (window as unknown as PlaylistStorageWindow).electron;
|
||||
await electron.dbUpsertAppPlaylist(playlist);
|
||||
}, {
|
||||
_id: row.id,
|
||||
title: row.title,
|
||||
macAddress: row.macAddress,
|
||||
portalUrl: row.portalUrl,
|
||||
isFullStalkerPortal: row.isFullStalkerPortal,
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
importDate: nowIso,
|
||||
lastUsage: nowIso,
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function openSeededPortal(page: Page, title: string): Promise<void> {
|
||||
await openSources(page);
|
||||
await sourceRowByTitle(page, title).first().click();
|
||||
await waitForStalkerCatalog(page);
|
||||
}
|
||||
|
||||
test('@electron @stalker import discovery resolves canonical, ministra-/c and reseller URLs', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
await resetMockServers(request, ['stalker']);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
// 1) Canonical Ministra endpoint pasted directly (#850): the old
|
||||
// import predicate missed `/server/load.php`, persisted the portal
|
||||
// as token-free and every content request answered the plain-text
|
||||
// "Authorization failed." — portal added, no content.
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: 'Canonical Load PHP',
|
||||
macAddress: CANONICAL_IMPORT_MAC,
|
||||
portalUrl: `${stalkerMockServer}/server/load.php`,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
expect(
|
||||
await readStoredPortalConfig(app.mainWindow, 'Canonical Load PHP')
|
||||
).toEqual({
|
||||
portalUrl: `${stalkerMockServer}/server/load.php`,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
|
||||
// 2) The `…/c` browser URL on a genuine Ministra host (#686/#755):
|
||||
// the old rewrite produced `…/portal.php`, which 404s there. The
|
||||
// probe must fall through the 404 to `server/load.php` and persist
|
||||
// full-portal mode.
|
||||
await openSources(app.mainWindow);
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: 'Ministra Slash C',
|
||||
macAddress: MINISTRA_IMPORT_MAC,
|
||||
portalUrl: `${stalkerMockServer}/ministra/c`,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
expect(
|
||||
await readStoredPortalConfig(app.mainWindow, 'Ministra Slash C')
|
||||
).toEqual({
|
||||
portalUrl: `${stalkerMockServer}/ministra/server/load.php`,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
|
||||
// 3) Reseller `…/c` URL: portal.php answers without a token, so the
|
||||
// pre-discovery behavior (portal.php endpoint, simple mode, no
|
||||
// handshake) must be preserved exactly.
|
||||
await openSources(app.mainWindow);
|
||||
await addStalkerPortal(app.mainWindow, {
|
||||
name: 'Reseller Panel',
|
||||
macAddress: RESELLER_IMPORT_MAC,
|
||||
portalUrl: `${stalkerMockServer}/c`,
|
||||
});
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
expect(
|
||||
await readStoredPortalConfig(app.mainWindow, 'Reseller Panel')
|
||||
).toEqual({
|
||||
portalUrl: `${stalkerMockServer}/portal.php`,
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@electron @stalker lazy repair fixes misclassified stored portals and never touches working ones', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
await resetMockServers(request, ['stalker']);
|
||||
|
||||
let app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
// Rows exactly as the pre-discovery code persisted them.
|
||||
await seedStalkerPlaylist(app.mainWindow, {
|
||||
id: 'repair-flag-only',
|
||||
title: 'Misclassified Canonical',
|
||||
macAddress: REPAIR_FLAG_MAC,
|
||||
portalUrl: `${stalkerMockServer}/server/load.php`,
|
||||
// The old import predicate persisted false for this URL.
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
await seedStalkerPlaylist(app.mainWindow, {
|
||||
id: 'repair-endpoint',
|
||||
title: 'Broken Portal PHP Rewrite',
|
||||
macAddress: REPAIR_ENDPOINT_MAC,
|
||||
// The old `…/c` rewrite on a genuine Ministra host: 404 forever.
|
||||
portalUrl: `${stalkerMockServer}/ministra/portal.php`,
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
await seedStalkerPlaylist(app.mainWindow, {
|
||||
id: 'healthy-reseller',
|
||||
title: 'Healthy Reseller',
|
||||
macAddress: HEALTHY_RESELLER_MAC,
|
||||
portalUrl: `${stalkerMockServer}/portal.php`,
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
|
||||
// Fresh session so the seeded rows load like any long-existing
|
||||
// playlist (the repair must work for users, not for this test).
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app = restarted;
|
||||
|
||||
// Misclassified canonical portal: the first content request answers
|
||||
// the plain-text auth failure, repair re-probes, proves the endpoint
|
||||
// enforces the token, flips ONLY the flag and retries — the catalog
|
||||
// must render in the same session.
|
||||
await openSeededPortal(app.mainWindow, 'Misclassified Canonical');
|
||||
await expect
|
||||
.poll(() =>
|
||||
readStoredPortalConfig(app.mainWindow, 'Misclassified Canonical')
|
||||
)
|
||||
.toEqual({
|
||||
portalUrl: `${stalkerMockServer}/server/load.php`,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
|
||||
// Dead portal.php rewrite: the 404 triggers the repair, which lands
|
||||
// on the canonical endpoint in full mode.
|
||||
await openSeededPortal(app.mainWindow, 'Broken Portal PHP Rewrite');
|
||||
await expect
|
||||
.poll(() =>
|
||||
readStoredPortalConfig(
|
||||
app.mainWindow,
|
||||
'Broken Portal PHP Rewrite'
|
||||
)
|
||||
)
|
||||
.toEqual({
|
||||
portalUrl: `${stalkerMockServer}/ministra/server/load.php`,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
|
||||
// Working reseller panel: browsing succeeds without any auth, so the
|
||||
// repair never runs and the stored row stays byte-identical — the
|
||||
// conservative core of the migration story.
|
||||
await openSeededPortal(app.mainWindow, 'Healthy Reseller');
|
||||
expect(
|
||||
await readStoredPortalConfig(app.mainWindow, 'Healthy Reseller')
|
||||
).toEqual({
|
||||
portalUrl: `${stalkerMockServer}/portal.php`,
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -100,10 +100,15 @@ ipcMain.handle(
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
throw {
|
||||
message: `HTTP Error: ${response.statusText}`,
|
||||
status: response.status,
|
||||
};
|
||||
// The numeric code must live in the MESSAGE: ipcRenderer
|
||||
// strips custom properties from rejected values, and the
|
||||
// renderer's endpoint discovery needs to tell a 404 (probe
|
||||
// next candidate) from a network failure (stop probing).
|
||||
const httpError = new Error(
|
||||
`HTTP Error ${response.status}: ${response.statusText}`
|
||||
) as Error & { status: number };
|
||||
httpError.status = response.status;
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
// Return the response data
|
||||
@@ -163,16 +168,26 @@ ipcMain.handle(
|
||||
);
|
||||
|
||||
// Format error response
|
||||
if (axios.isAxiosError(error)) {
|
||||
const errorResponse = {
|
||||
type: 'ERROR',
|
||||
message:
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
'Failed to fetch data from Stalker portal',
|
||||
status: error.response?.status || 500,
|
||||
};
|
||||
throw errorResponse;
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
// A real HTTP response (5xx lands here via validateStatus).
|
||||
// Same parseable message shape as the 4xx branch: only the
|
||||
// message crosses ipcRenderer.invoke, and the renderer's
|
||||
// endpoint discovery must tell "this endpoint answered 5xx —
|
||||
// try the next candidate" from a host-level failure.
|
||||
const httpError = new Error(
|
||||
`HTTP Error ${error.response.status}: ${error.response.statusText ?? ''}`
|
||||
) as Error & { status: number };
|
||||
httpError.status = error.response.status;
|
||||
throw httpError;
|
||||
} else if (axios.isAxiosError(error)) {
|
||||
// A real Error, not a plain object: Electron serializes
|
||||
// handler rejections via toString(), so a plain object
|
||||
// reaches the renderer as "[object Object]" and its
|
||||
// timeout-vs-connection classification is lost — discovery
|
||||
// would stop probing as if the whole host were unreachable.
|
||||
throw new Error(
|
||||
error.message || 'Failed to fetch data from Stalker portal'
|
||||
);
|
||||
} else if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
|
||||
@@ -27,30 +27,26 @@ Then in IPTVnator, add a new Stalker portal:
|
||||
|
||||
- **Portal URL**: `http://localhost:3210/portal.php` (tolerant panel-style endpoint)
|
||||
or `http://localhost:3210/stalker_portal/server/load.php` (canonical Ministra
|
||||
endpoint — see [Two endpoints](#two-endpoints-tolerant-vs-strict) below)
|
||||
endpoint — see [Endpoints](#endpoints-tolerant-strict-and-the-ministra-host) below)
|
||||
- **MAC Address**: one of the predefined scenarios below (or any MAC for auto-generated data)
|
||||
|
||||
## Two endpoints: tolerant vs strict
|
||||
## Endpoints: tolerant, strict, and the /ministra host
|
||||
|
||||
The same actions are served at two paths with deliberately different strictness,
|
||||
because the app treats them differently: a URL containing `/stalker_portal` is
|
||||
imported as a **full portal** (handshake + token + watchdog), anything else as a
|
||||
**simple portal** (no authentication at all).
|
||||
The same actions are served at several paths with deliberately different
|
||||
strictness. Since endpoint discovery landed, the app no longer guesses the
|
||||
portal mode from the URL shape: at import it probes `portal.php` →
|
||||
`server/load.php` → `stalker_portal/server/load.php` and classifies each
|
||||
endpoint by observed behavior (token-less `get_genres` answering data ⇒
|
||||
token-free panel; the plain-text auth failure ⇒ full portal, confirmed by a
|
||||
real handshake + `get_profile`). The mock's split makes every branch of that
|
||||
classification exercisable:
|
||||
|
||||
| Path | Behaviour |
|
||||
|---|---|
|
||||
| `/portal.php` | Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild. |
|
||||
| `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware. |
|
||||
| `/server/load.php` | Strict. The second full-portal URL shape the app recognizes; enforced identically. |
|
||||
|
||||
> **Known app inconsistency:** `StalkerSessionService.isFullStalkerPortal`
|
||||
> classifies `/server/load.php` as a full portal, but the import dialog's
|
||||
> `isFullStalkerPortalUrl` checks only for `/stalker_portal`, so importing a bare
|
||||
> `…/server/load.php` URL persists `isFullStalkerPortal: false` and the app skips
|
||||
> the handshake. The mock is deliberately faithful to a **real** portal here
|
||||
> (that path enforces auth), which makes it the right fixture to drive the
|
||||
> upcoming fix that unifies those two predicates. Until then, import full
|
||||
> portals through a `/stalker_portal/...` URL.
|
||||
| `/portal.php` | Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild — discovery classifies it as a token-free simple portal. |
|
||||
| `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware — discovery classifies it as a full portal. |
|
||||
| `/server/load.php` | Strict, enforced identically. Importing this bare canonical URL now authenticates (the historical import/runtime predicate divergence that skipped the handshake here is fixed). |
|
||||
| `/ministra/server/load.php` | Strict. The `/ministra/*` prefix simulates a **genuine Ministra host**: `/ministra/portal.php` 404s like a real installation (portal.php is a reseller alias official Stalker never ships), so `http://localhost:3210/ministra/c` exercises the probe's 404 fallthrough end to end. |
|
||||
|
||||
The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can
|
||||
actually get wrong:
|
||||
|
||||
@@ -96,6 +96,14 @@ app.use('/portal.php', portalRouter);
|
||||
app.use('/stalker_portal/server/load.php', createPortalRouter(true));
|
||||
app.use('/server/load.php', createPortalRouter(true));
|
||||
|
||||
// Genuine-Ministra host simulation: everything under /ministra serves ONLY
|
||||
// the canonical `server/load.php` endpoint — `/ministra/portal.php` 404s like
|
||||
// a real Stalker/Ministra installation (portal.php is a reseller-panel alias
|
||||
// the official middleware never ships). This is what lets e2e prove the
|
||||
// endpoint-discovery fallthrough: `http://host/ministra/c` must probe
|
||||
// portal.php, hit the 404, and land on server/load.php in full-portal mode.
|
||||
app.use('/ministra/server/load.php', createPortalRouter(true));
|
||||
|
||||
/**
|
||||
* Mirror of the app's full-portal predicates (`isFullStalkerPortal` checks
|
||||
* `/stalker_portal/` or `/server/load.php`; import-time normalization checks
|
||||
|
||||
@@ -217,32 +217,6 @@ async function addFullStalkerPortal(
|
||||
}
|
||||
}
|
||||
|
||||
/** Portal actions the app sent, in order, with the token each carried. */
|
||||
function recordPortalActions(page: Page): {
|
||||
actions: string[];
|
||||
tokensByAction: Map<string, string | null>;
|
||||
} {
|
||||
const actions: string[] = [];
|
||||
const tokensByAction = new Map<string, string | null>();
|
||||
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (!url.pathname.endsWith('/stalker')) {
|
||||
return;
|
||||
}
|
||||
const action = url.searchParams.get('action');
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
actions.push(action);
|
||||
if (!tokensByAction.has(action)) {
|
||||
tokensByAction.set(action, url.searchParams.get('token'));
|
||||
}
|
||||
});
|
||||
|
||||
return { actions, tokensByAction };
|
||||
}
|
||||
|
||||
const CONTENT_ACTIONS = [
|
||||
'get_categories',
|
||||
'get_genres',
|
||||
@@ -888,7 +862,8 @@ test.describe('@stalker full portal authentication', () => {
|
||||
test('handshakes and authenticates before loading content', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { actions, tokensByAction } = recordPortalActions(page);
|
||||
const requests = recordPortalRequests(page);
|
||||
const actionsInOrder = () => requests.map((entry) => entry.action);
|
||||
|
||||
await addFullStalkerPortal(page, { mac: AUTH_FLOW_MAC });
|
||||
|
||||
@@ -898,32 +873,48 @@ test.describe('@stalker full portal authentication', () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Endpoint discovery classifies the portal with a token-less
|
||||
// get_genres probe BEFORE any authentication: the plain-text
|
||||
// "Authorization failed." answer is what proves this endpoint
|
||||
// enforces the token, so the probe must precede the handshake.
|
||||
const probeIndex = requests.findIndex(
|
||||
(entry) => entry.action === 'get_genres'
|
||||
);
|
||||
expect(probeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(requests[probeIndex].token).toBeFalsy();
|
||||
|
||||
const actions = actionsInOrder();
|
||||
expect(actions).toContain('handshake');
|
||||
expect(actions).toContain('get_profile');
|
||||
expect(probeIndex).toBeLessThan(actions.indexOf('handshake'));
|
||||
expect(actions.indexOf('handshake')).toBeLessThan(
|
||||
actions.indexOf('get_profile')
|
||||
);
|
||||
|
||||
const contentAction = actions.find((action) =>
|
||||
['get_categories', 'get_genres'].includes(action)
|
||||
);
|
||||
expect(contentAction).toBeDefined();
|
||||
expect(actions.indexOf('get_profile')).toBeLessThan(
|
||||
actions.indexOf(contentAction as string)
|
||||
);
|
||||
|
||||
// Content requests must carry the token; the handshake must not.
|
||||
expect(tokensByAction.get('handshake')).toBeFalsy();
|
||||
expect(tokensByAction.get(contentAction as string)).toBeTruthy();
|
||||
// The first authenticated content request comes after get_profile
|
||||
// and must carry the adopted token; the handshake must not.
|
||||
const contentEntry = requests
|
||||
.slice(actions.indexOf('get_profile') + 1)
|
||||
.find((entry) => CONTENT_ACTIONS.includes(entry.action));
|
||||
expect(contentEntry).toBeDefined();
|
||||
expect(contentEntry?.token).toBeTruthy();
|
||||
expect(
|
||||
requests.find((entry) => entry.action === 'handshake')?.token
|
||||
).toBeFalsy();
|
||||
|
||||
// The full-portal workflow must also keep the watchdog alive — an
|
||||
// authenticated get_events fires immediately (init=1) on activation.
|
||||
// Without this assertion the suite would stay green if the watchdog
|
||||
// wiring silently died, because its failures are swallowed by design.
|
||||
await expect
|
||||
.poll(() => actions.includes('get_events'), { timeout: 30_000 })
|
||||
.poll(
|
||||
() =>
|
||||
requests.some(
|
||||
(entry) => entry.action === 'get_events' && entry.token
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
expect(tokensByAction.get('get_events')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('never surfaces the portal plain-text auth failure as content', async ({
|
||||
|
||||
@@ -277,6 +277,8 @@ export class ElectronService extends DataService {
|
||||
requestId?: string;
|
||||
token?: string;
|
||||
serialNumber?: string;
|
||||
/** Endpoint-discovery probes expect failures; no error snackbar. */
|
||||
silent?: boolean;
|
||||
}) {
|
||||
const context = createPortalDebugRequestContext({
|
||||
provider: 'stalker',
|
||||
@@ -295,13 +297,15 @@ export class ElectronService extends DataService {
|
||||
} catch (err: unknown) {
|
||||
const errorInfo = this.getErrorDetails(err);
|
||||
this.logger.error('Stalker request error:', err);
|
||||
this.snackBar.open(
|
||||
`Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`,
|
||||
'Close',
|
||||
{
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
if (!payload.silent) {
|
||||
this.snackBar.open(
|
||||
`Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`,
|
||||
'Close',
|
||||
{
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EMPTY } from 'rxjs';
|
||||
import {
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
PLAYLIST_UPDATE,
|
||||
STALKER_REQUEST,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PwaService } from './pwa.service';
|
||||
|
||||
@@ -78,6 +79,47 @@ describe('PwaService', () => {
|
||||
expect(http.match(() => true)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('surfaces the stalker proxy error envelope as an HTTP error instead of undefined', async () => {
|
||||
// The web-backend converts an upstream 404 into HTTP 200 with a
|
||||
// `{ message, status }` body and NO `payload` key. Unwrapping
|
||||
// `payload` silently returned undefined, so endpoint discovery and
|
||||
// the lazy portal repair could never classify a dead endpoint.
|
||||
// JSDOM ships no global fetch — install one for the call under test.
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ message: 'Not Found', status: 404 }),
|
||||
} as unknown as Response) as unknown as typeof fetch;
|
||||
|
||||
const request = service.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: 'http://portal.example/portal.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
params: { action: 'get_genres' },
|
||||
silent: true,
|
||||
}) as Promise<unknown>;
|
||||
const outcome = (request as Promise<unknown>).then(
|
||||
() => {
|
||||
throw new Error('expected rejection');
|
||||
},
|
||||
(error: unknown) => error
|
||||
);
|
||||
|
||||
// Provider-target registration goes through HttpClient first.
|
||||
await Promise.resolve();
|
||||
const registration = http.expectOne((candidate) =>
|
||||
candidate.url.endsWith('/provider-targets')
|
||||
);
|
||||
registration.flush({ targetId: 'target-1' });
|
||||
|
||||
const error = (await outcome) as Error & { status?: number };
|
||||
expect(error.message).toBe('HTTP Error 404: Not Found');
|
||||
expect(error.status).toBe(404);
|
||||
// silent flag: discovery probes expect failures — no snackbar.
|
||||
expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled();
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('sends Stalker credentials as /stalker control params, including the serial', async () => {
|
||||
// JSDOM ships no fetch; install one for the proxy call.
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
|
||||
@@ -139,6 +139,7 @@ export class PwaService extends DataService {
|
||||
params: Record<string, string>;
|
||||
token?: string;
|
||||
serialNumber?: string;
|
||||
silent?: boolean;
|
||||
}
|
||||
) as T;
|
||||
}
|
||||
@@ -481,6 +482,8 @@ export class PwaService extends DataService {
|
||||
macAddress: string;
|
||||
token?: string;
|
||||
serialNumber?: string;
|
||||
/** Endpoint-discovery probes expect failures; no error snackbar. */
|
||||
silent?: boolean;
|
||||
}) {
|
||||
let context = createPortalDebugRequestContext({
|
||||
provider: 'stalker',
|
||||
@@ -534,6 +537,29 @@ export class PwaService extends DataService {
|
||||
|
||||
// Parse and return the JSON response
|
||||
const responseBody = await response.json();
|
||||
|
||||
// The proxy converts upstream provider failures (404 on an
|
||||
// absent endpoint, 5xx) into an HTTP 200 `{ message, status }`
|
||||
// body WITHOUT a `payload` key. Surface those as errors carrying
|
||||
// the status so endpoint discovery and the lazy portal repair
|
||||
// can classify them — unwrapping `payload` here silently
|
||||
// returned `undefined`, making a dead endpoint look like an
|
||||
// empty answer and unreachable to the repair.
|
||||
if (
|
||||
responseBody &&
|
||||
typeof responseBody === 'object' &&
|
||||
!('payload' in responseBody) &&
|
||||
typeof responseBody.status === 'number'
|
||||
) {
|
||||
const proxyError = new Error(
|
||||
`HTTP Error ${responseBody.status}: ${
|
||||
responseBody.message ?? ''
|
||||
}`
|
||||
) as Error & { status: number };
|
||||
proxyError.status = responseBody.status;
|
||||
throw proxyError;
|
||||
}
|
||||
|
||||
logPortalDebugEvent(
|
||||
createPortalDebugSuccessEvent(context, responseBody)
|
||||
);
|
||||
@@ -543,13 +569,15 @@ export class PwaService extends DataService {
|
||||
logPortalDebugEvent(createPortalDebugErrorEvent(context, err));
|
||||
this.logger.error('Stalker request error:', err);
|
||||
|
||||
this.snackBar.open(
|
||||
`Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`,
|
||||
'Close',
|
||||
{
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
if (!payload.silent) {
|
||||
this.snackBar.open(
|
||||
`Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`,
|
||||
'Close',
|
||||
{
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,19 +39,22 @@ Stalker portals use MAC address as the primary credential. The mock server follo
|
||||
|
||||
No files or databases are written. All state (generated content + favorites + portal sessions) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs.
|
||||
|
||||
### Two Endpoints With Different Strictness
|
||||
### Endpoints With Different Strictness
|
||||
|
||||
The app decides how to talk to a portal from the shape of its URL: a URL
|
||||
containing `/stalker_portal` is imported as a **full portal** (handshake,
|
||||
`Authorization: Bearer`, watchdog), anything else as a **simple portal** with no
|
||||
authentication at all. The mock therefore serves the same action set at two
|
||||
paths:
|
||||
The app classifies a portal by observed behavior, not by URL shape: endpoint
|
||||
discovery (see `docs/architecture/stalker-portal.md`, "Portal Mode and
|
||||
Endpoint Discovery") probes candidates at import and on lazy repair, treating
|
||||
a token-less content request that returns data as a token-free panel and the
|
||||
middleware's plain-text auth failure as a token-enforcing full portal. The
|
||||
mock serves the same action set at several paths so every classification
|
||||
branch is exercisable:
|
||||
|
||||
| Path | Router | Behaviour |
|
||||
|---|---|---|
|
||||
| `/portal.php` | `createPortalRouter(false)` | Tolerant: ignores the token and the MAC format, like most reseller panels |
|
||||
| `/stalker_portal/server/load.php` | `createPortalRouter(true)` | Strict: enforces both, like the real middleware |
|
||||
| `/server/load.php` | `createPortalRouter(true)` | Strict: the second URL shape `isFullStalkerPortal` recognizes |
|
||||
| `/server/load.php` | `createPortalRouter(true)` | Strict: the bare canonical Ministra shape, enforced identically |
|
||||
| `/ministra/server/load.php` | `createPortalRouter(true)` | Strict; the `/ministra/*` prefix has **no portal.php** (404s like genuine Ministra), so `/ministra/c` proves the probe's 404 fallthrough |
|
||||
|
||||
The `/stalker` proxy route applies the same rule through
|
||||
`isFullPortalUrlShape()` — every URL the client would authenticate against is
|
||||
|
||||
@@ -48,10 +48,97 @@ Primary route tree lives in
|
||||
|
||||
1. Angular Stalker screens call methods/resources in `StalkerStore`.
|
||||
2. `StalkerStore` builds request params based on selected content type and current view state.
|
||||
3. Requests go through `DataService.sendIpcEvent(STALKER_REQUEST, ...)` or `StalkerSessionService` (full portal auth).
|
||||
3. Every portal API call funnels through `executeStalkerRequest()`
|
||||
(`libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts`),
|
||||
the single choke point that decides the transport per portal mode:
|
||||
full portals go through `StalkerSessionService` (handshake + Bearer token +
|
||||
retry), token-free panels call
|
||||
`DataService.sendIpcEvent(STALKER_REQUEST, ...)` directly. It also hooks
|
||||
the lazy portal repair (see "Portal Mode and Endpoint Discovery").
|
||||
4. Electron main process handles `STALKER_REQUEST` in
|
||||
`apps/electron-backend/src/app/events/stalker.events.ts`.
|
||||
5. Axios calls Stalker `load.php` API with required headers/cookies and returns the raw `response.data` to the renderer; normalization happens in the store feature slices.
|
||||
5. Axios calls the portal's persisted API endpoint (`portal.php` on
|
||||
reseller panels, `server/load.php` on canonical Stalker/Ministra) with
|
||||
required headers/cookies and returns the raw `response.data` to the
|
||||
renderer; normalization happens in the store feature slices.
|
||||
|
||||
## Portal Mode and Endpoint Discovery
|
||||
|
||||
Two portal modes exist, persisted per playlist as
|
||||
`Playlist.isFullStalkerPortal`:
|
||||
|
||||
- **Full portal** (canonical Stalker/Ministra middleware): every request
|
||||
except `handshake`, `get_profile`, `get_localization`, and `do_auth`
|
||||
requires `Authorization: Bearer <token>`; auth failures are HTTP 200 with a
|
||||
plain-text body (`Authorization failed.`, `Access denied.`,
|
||||
`Unauthorized request.`), never a 401/403. 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.
|
||||
- **Simple portal** (reseller-style `portal.php` panels): no auth lifecycle
|
||||
at all — requests carry only the `mac=` cookie.
|
||||
|
||||
The single predicate lives in `@iptvnator/shared/interfaces`
|
||||
(`stalker-portal-mode.util.ts`): `isFullStalkerPortalPlaylist()` treats the
|
||||
persisted flag as authoritative and falls back to the URL shape
|
||||
(`isFullStalkerPortalUrl()`: `/stalker_portal` or `/server/load.php`) only
|
||||
for legacy rows where the flag is undefined. Historically three diverging
|
||||
copies of this rule existed (import, session service, legacy-flag migration)
|
||||
and their drift shipped broken configurations (#850, #686, #755); no new
|
||||
consumer may re-implement the rule.
|
||||
|
||||
**Endpoint discovery (import).** `portal.php` does not exist in official
|
||||
Stalker/Ministra — it is a reseller-panel alias; the canonical endpoint
|
||||
derived from a `…/c` URL is `<base>/server/load.php`. Instead of guessing
|
||||
from the URL shape, `StalkerPortalDiscoveryService`
|
||||
(`libs/portal/stalker/data-access`) probes candidates in order — the pasted
|
||||
URL itself when it already names a `.php` endpoint, then `<base>/portal.php`
|
||||
→ `<base>/server/load.php` → `<base>/stalker_portal/server/load.php` — and
|
||||
classifies each endpoint by observed behavior: a token-less
|
||||
`itv/get_genres` that returns data proves a token-free panel; the plain-text
|
||||
auth failure proves the endpoint enforces the token, which is confirmed by
|
||||
running the real handshake + `get_profile`. The import dialog persists the
|
||||
proven endpoint and mode. When no candidate answers at all, panel-style URLs
|
||||
fall back to the pre-discovery behavior (legacy `…/c` → `portal.php` rewrite,
|
||||
simple mode, import succeeds with a warning) so temporarily offline panels
|
||||
can still be added; canonical-shaped URLs abort like the old mandatory
|
||||
handshake did (both classifications run on the normalized
|
||||
`origin + pathname` form). Probe failure sequencing: ANY resolvable HTTP
|
||||
status moves to the next candidate — 4xx means the endpoint is absent, a
|
||||
5xx can be one broken handler beside a healthy sibling — and 401/403
|
||||
specifically classify as auth-required (the handshake is attempted, for
|
||||
middlewares that answer HTTP auth codes instead of the stock 200 +
|
||||
plain-text body). Status-less TIMEOUTS also continue (a single handler can
|
||||
hang); only connection-level failures (refused, unresolvable host) abort
|
||||
discovery, since every candidate shares the host.
|
||||
|
||||
**Lazy repair (existing playlists).** The flag is frozen in the DB, so
|
||||
records persisted by the old guess stay broken without repair — but a large
|
||||
share of users are on working reseller panels, and only probing can tell the
|
||||
two apart, so there is deliberately **no eager one-shot migration**. Instead
|
||||
`StalkerPortalRepairService` re-probes a portal only after a request
|
||||
actually failed with a shape that a wrong endpoint/mode produces (the
|
||||
plain-text auth bodies AND their JSON envelopes (`js.error`/`js.msg`),
|
||||
HTTP 404 (endpoint absent), HTTP 401/403 (endpoint behind an HTTP auth
|
||||
gate), and terminal handshake/profile errors — never timeouts or other
|
||||
network failures), at most once per SOURCE CONFIGURATION (endpoint + mode + MAC +
|
||||
identity fingerprint) per playlist per session — an edited configuration
|
||||
may probe when it fails, while every already-probed one stays latched for
|
||||
the session — and persists only a configuration discovery has proven to
|
||||
answer, and only when it differs from the failing one. A repaired configuration is applied immediately via an
|
||||
in-session override inside `executeStalkerRequest()` (stale store snapshots
|
||||
keep working) and persisted through `PlaylistsService.transformPlaylistMeta`
|
||||
— the verification and the patch run in ONE slot of the per-playlist write
|
||||
queue, so a user edit that is queued but not yet committed wins over the
|
||||
repair instead of being overwritten; the transform patches the freshly read
|
||||
row (`portalUrl` + `isFullStalkerPortal` only, so user state can never be
|
||||
clobbered) and returns null to abort. Deletion runs through the same queue,
|
||||
so a repair can never resurrect a playlist deleted mid-probe. Portals
|
||||
that work are never probed, let alone rewritten. E2E coverage:
|
||||
`apps/electron-backend-e2e/src/stalker-portal-discovery.e2e.ts` against the
|
||||
mock's tolerant `/portal.php`, strict `/server/load.php`, and
|
||||
`portal.php`-less `/ministra/*` hosts.
|
||||
|
||||
## Main UI Components
|
||||
|
||||
@@ -606,14 +693,19 @@ counter, MAC/phone, and portal details.
|
||||
Data flow (two sources, cached-first):
|
||||
|
||||
- Cached: `Playlist.stalkerAccountInfo`, captured from `get_profile` at
|
||||
import time for full `/stalker_portal/` installations. The dialog loads it
|
||||
by playlist id (the meta row does not carry it) and renders instantly with
|
||||
a "Saved data" badge.
|
||||
import time for portals discovery classified as FULL (endpoint discovery
|
||||
decides this by behavior, so a token-enforcing `portal.php` panel is a
|
||||
full portal too). The dialog loads it by playlist id (the meta row does
|
||||
not carry it) and renders instantly with a "Saved data" badge.
|
||||
- Fresh: `StalkerAccountInfoService`
|
||||
(`libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts`).
|
||||
Full portals re-run handshake + `get_profile`; `portal.php` panels are
|
||||
queried with `account_info/get_main_info`, whose field set varies between
|
||||
panels and is mapped best-effort (absent fields render nothing). A failed
|
||||
Routing follows the observed MODE, never the endpoint shape: full-mode
|
||||
portals re-run handshake + `get_profile`, simple-mode panels are queried
|
||||
with `account_info/get_main_info`, whose field set varies between panels
|
||||
and is mapped best-effort (absent fields render nothing). Both directions
|
||||
re-route after a lazy repair changes the mode mid-request, so a portal
|
||||
repaired from simple to full switches to the profile flow and vice
|
||||
versa. A failed
|
||||
refresh keeps the cached snapshot and flags it. The two no-data outcomes
|
||||
differ: a portal that answers but publishes no account facts (and no
|
||||
cached snapshot exists) renders the ready-state "No account details"
|
||||
|
||||
@@ -59,7 +59,16 @@ These are currently reachable on the store object and used internally by compute
|
||||
- `getContentResource` (resource)
|
||||
- `serialSeasonsResource` (resource)
|
||||
- `vodSeriesSeasonsResource` (resource)
|
||||
- `makeStalkerRequest(...)`
|
||||
|
||||
Removed:
|
||||
|
||||
- `makeStalkerRequest(...)` — deleted with the endpoint-discovery work. It
|
||||
was production-dead (no caller outside its own spec) and carried a fourth
|
||||
private copy of the portal-mode branch. Every Stalker request goes through
|
||||
`executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`), which
|
||||
owns mode routing plus the lazy portal repair; no facade alias is provided
|
||||
because reinstating one would reintroduce the drift the shared predicate
|
||||
exists to prevent.
|
||||
|
||||
During refactor:
|
||||
|
||||
|
||||
+70
-9
@@ -2,17 +2,23 @@ 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 { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerPortalImportComponent } from './stalker-portal-import.component';
|
||||
|
||||
describe('StalkerPortalImportComponent identity handling', () => {
|
||||
let component: StalkerPortalImportComponent;
|
||||
let stalkerSession: { authenticate: jest.Mock };
|
||||
let portalDiscovery: { discover: jest.Mock };
|
||||
let store: { dispatch: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
stalkerSession = {
|
||||
authenticate: jest.fn().mockResolvedValue({ token: 'token-1' }),
|
||||
portalDiscovery = {
|
||||
discover: jest.fn().mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
}),
|
||||
};
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
@@ -20,7 +26,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: StalkerSessionService, useValue: stalkerSession },
|
||||
{
|
||||
provide: StalkerPortalDiscoveryService,
|
||||
useValue: portalDiscovery,
|
||||
},
|
||||
{ provide: Store, useValue: store },
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
@@ -54,8 +63,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(stalkerSession.authenticate).toHaveBeenCalledWith(
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
expect(portalDiscovery.discover).toHaveBeenCalledWith(
|
||||
'https://portal.example.com/stalker_portal/c',
|
||||
'00:1A:79:AA:BB:CC',
|
||||
{
|
||||
serialNumber: 'CUSTOMSN123',
|
||||
@@ -69,6 +78,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist).toEqual(
|
||||
expect.objectContaining({
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
stalkerToken: 'token-1',
|
||||
stalkerSerialNumber: 'CUSTOMSN123',
|
||||
stalkerDeviceId1: 'DEVICE-ID-1',
|
||||
stalkerDeviceId2: 'DEVICE-ID-2',
|
||||
@@ -99,8 +112,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(stalkerSession.authenticate).toHaveBeenCalledWith(
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
expect(portalDiscovery.discover).toHaveBeenCalledWith(
|
||||
'https://portal.example.com/stalker_portal/c',
|
||||
'00:1A:79:AA:BB:CC',
|
||||
{}
|
||||
);
|
||||
@@ -117,4 +130,52 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
expect(playlist.signature1).toBeUndefined();
|
||||
expect(playlist.signature2).toBeUndefined();
|
||||
});
|
||||
|
||||
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.
|
||||
portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-3',
|
||||
title: 'Query Panel',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://panel.example.com/c?redirect=/server/load.php',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist).toEqual(
|
||||
expect.objectContaining({
|
||||
portalUrl: 'https://panel.example.com/portal.php',
|
||||
isFullStalkerPortal: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a query-carrying /c URL in the unreachable-host fallback', async () => {
|
||||
// Offline panel: discovery finds nothing, the legacy guess imports
|
||||
// anyway — but the suffix rewrite must run on the PATH, or
|
||||
// `/c?key=value` would persist the browser page instead of
|
||||
// portal.php (and a 200 HTML answer is not a repair trigger later).
|
||||
portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-2',
|
||||
title: 'Offline Panel',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://panel.example.com/c?key=value',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist).toEqual(
|
||||
expect.objectContaining({
|
||||
portalUrl: 'https://panel.example.com/portal.php',
|
||||
isFullStalkerPortal: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+82
-98
@@ -13,11 +13,17 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
legacyTransformStalkerPortalUrl,
|
||||
normalizeStalkerPortalInputUrl,
|
||||
StalkerPortalDiscoveryService,
|
||||
StalkerPortalIdentity,
|
||||
StalkerSessionService,
|
||||
normalizeStalkerPortalIdentity,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { createRandomId, Playlist } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
createRandomId,
|
||||
isFullStalkerPortalUrl,
|
||||
Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
@@ -72,7 +78,7 @@ export class StalkerPortalImportComponent {
|
||||
userAgent: new FormControl(''),
|
||||
});
|
||||
|
||||
private readonly stalkerSessionService = inject(StalkerSessionService);
|
||||
private readonly portalDiscovery = inject(StalkerPortalDiscoveryService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
readonly translate = inject(TranslateService);
|
||||
@@ -107,9 +113,6 @@ export class StalkerPortalImportComponent {
|
||||
try {
|
||||
const formValue = this.form.getRawValue();
|
||||
const originalUrl = formValue.portalUrl ?? '';
|
||||
const transformedUrl = this.transformPortalUrl(originalUrl);
|
||||
const isFullStalkerPortal =
|
||||
this.isFullStalkerPortalUrl(originalUrl);
|
||||
const stalkerIdentity = normalizeStalkerPortalIdentity({
|
||||
serialNumber: formValue.serialNumber ?? undefined,
|
||||
deviceId1: formValue.deviceId1 ?? undefined,
|
||||
@@ -118,55 +121,88 @@ export class StalkerPortalImportComponent {
|
||||
signature2: formValue.signature2 ?? undefined,
|
||||
});
|
||||
|
||||
// Probe candidate endpoints and classify the portal by observed
|
||||
// behavior (does it enforce the handshake token?) instead of
|
||||
// guessing from the URL shape — the guess persisted broken
|
||||
// configurations for canonical `…/server/load.php` portals and
|
||||
// rewrote `…/c` to a `portal.php` official Ministra never serves.
|
||||
const discovery = await this.portalDiscovery.discover(
|
||||
originalUrl,
|
||||
formValue.macAddress ?? '',
|
||||
stalkerIdentity
|
||||
);
|
||||
|
||||
let portalUrl: string;
|
||||
let isFullStalkerPortal: boolean;
|
||||
let stalkerToken: string | undefined;
|
||||
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
|
||||
|
||||
// For full stalker portal URLs, perform handshake and get profile
|
||||
if (isFullStalkerPortal) {
|
||||
try {
|
||||
const authResult =
|
||||
await this.stalkerSessionService.authenticate(
|
||||
transformedUrl,
|
||||
formValue.macAddress ?? '',
|
||||
stalkerIdentity
|
||||
);
|
||||
if (discovery.status === 'resolved') {
|
||||
portalUrl = discovery.portalUrl;
|
||||
isFullStalkerPortal = discovery.isFullStalkerPortal;
|
||||
stalkerToken = discovery.token;
|
||||
|
||||
stalkerToken = authResult.token;
|
||||
if (discovery.accountInfo) {
|
||||
stalkerAccountInfo = {
|
||||
login: discovery.accountInfo.login,
|
||||
expireDate: discovery.accountInfo.expire_date,
|
||||
tariffPlanName:
|
||||
discovery.accountInfo.tariff_plan_name,
|
||||
status: discovery.accountInfo.status,
|
||||
};
|
||||
}
|
||||
|
||||
if (authResult.accountInfo) {
|
||||
stalkerAccountInfo = {
|
||||
login: authResult.accountInfo.login,
|
||||
expireDate: authResult.accountInfo.expire_date,
|
||||
tariffPlanName:
|
||||
authResult.accountInfo.tariff_plan_name,
|
||||
status: authResult.accountInfo.status,
|
||||
};
|
||||
}
|
||||
|
||||
// Show success notification with account info if available
|
||||
if (stalkerAccountInfo?.expireDate) {
|
||||
const expireDate = new Date(
|
||||
stalkerAccountInfo.expireDate * 1000
|
||||
);
|
||||
this.snackBar.open(
|
||||
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
|
||||
undefined,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[StalkerImport] Authentication failed:',
|
||||
error
|
||||
if (stalkerAccountInfo?.expireDate) {
|
||||
const expireDate = new Date(
|
||||
stalkerAccountInfo.expireDate * 1000
|
||||
);
|
||||
this.snackBar.open(
|
||||
'Failed to authenticate with portal. Please check URL and MAC address.',
|
||||
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
{ duration: 3000 }
|
||||
);
|
||||
this.isLoading.set(false);
|
||||
return;
|
||||
}
|
||||
} else if (discovery.status === 'auth-rejected') {
|
||||
console.error(
|
||||
'[StalkerImport] Authentication failed:',
|
||||
discovery.error
|
||||
);
|
||||
this.snackBar.open(
|
||||
'Failed to authenticate with portal. Please check URL and MAC address.',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
return;
|
||||
} else if (
|
||||
isFullStalkerPortalUrl(
|
||||
normalizeStalkerPortalInputUrl(originalUrl) ?? originalUrl
|
||||
)
|
||||
) {
|
||||
// Unreachable host on a canonical-portal URL shape: the old
|
||||
// flow aborted here too (its mandatory handshake could not
|
||||
// succeed either).
|
||||
this.snackBar.open(
|
||||
'Failed to authenticate with portal. Please check URL and MAC address.',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
// Unreachable host on a panel-style URL: import with the
|
||||
// legacy guess exactly like before discovery existed, so a
|
||||
// temporarily offline panel can still be added. The lazy
|
||||
// portal repair re-probes on the first real failure.
|
||||
// Normalized first: the legacy suffix rewrites run on the
|
||||
// path, so a query/fragment must not hide a trailing `/c`.
|
||||
portalUrl = legacyTransformStalkerPortalUrl(
|
||||
normalizeStalkerPortalInputUrl(originalUrl) ?? originalUrl
|
||||
);
|
||||
isFullStalkerPortal = false;
|
||||
this.snackBar.open(
|
||||
'Portal did not respond; added without validation.',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -180,7 +216,7 @@ export class StalkerPortalImportComponent {
|
||||
|
||||
const playlist: Playlist = {
|
||||
...playlistFormValue,
|
||||
portalUrl: transformedUrl,
|
||||
portalUrl,
|
||||
isFullStalkerPortal,
|
||||
stalkerToken,
|
||||
stalkerAccountInfo,
|
||||
@@ -194,14 +230,6 @@ export class StalkerPortalImportComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the URL is a full stalker portal URL that requires handshake authentication
|
||||
* Pattern: example.com/stalker_portal/c or example.com/stalker_portal/...
|
||||
*/
|
||||
isFullStalkerPortalUrl(url: string): boolean {
|
||||
return url.includes('/stalker_portal');
|
||||
}
|
||||
|
||||
private toPlaylistIdentityFields(identity: StalkerPortalIdentity): {
|
||||
stalkerSerialNumber?: string;
|
||||
stalkerDeviceId1?: string;
|
||||
@@ -228,48 +256,4 @@ export class StalkerPortalImportComponent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the portal URL to the correct API endpoint
|
||||
* - Simple URL (example.com/c) -> example.com/portal.php
|
||||
* - Full stalker portal (example.com/stalker_portal/c) -> example.com/stalker_portal/server/load.php
|
||||
*/
|
||||
transformPortalUrl(url: string): string {
|
||||
// Remove trailing slashes
|
||||
url = url.replace(/\/+$/, '');
|
||||
|
||||
// Case 1: Simple URL ending with /c -> convert to /portal.php
|
||||
if (url.endsWith('/c')) {
|
||||
// Check if it's a full stalker portal URL
|
||||
if (url.includes('/stalker_portal')) {
|
||||
// example.com/stalker_portal/c -> example.com/stalker_portal/server/load.php
|
||||
return url.replace(
|
||||
/\/stalker_portal\/c$/,
|
||||
'/stalker_portal/server/load.php'
|
||||
);
|
||||
}
|
||||
// Simple URL: example.com/c -> example.com/portal.php
|
||||
return url.replace(/\/c$/, '/portal.php');
|
||||
}
|
||||
|
||||
// Case 2: Full stalker portal URL without /c at the end
|
||||
if (
|
||||
url.includes('/stalker_portal') &&
|
||||
!url.includes('/server/load.php')
|
||||
) {
|
||||
// example.com/stalker_portal -> example.com/stalker_portal/server/load.php
|
||||
if (url.endsWith('/stalker_portal')) {
|
||||
return url + '/server/load.php';
|
||||
}
|
||||
// If it has other path segments after /stalker_portal, append server/load.php
|
||||
if (!url.endsWith('/load.php')) {
|
||||
return url.replace(
|
||||
/\/stalker_portal(\/.*)?$/,
|
||||
'/stalker_portal/server/load.php'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise keep the provided url
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,12 @@ import {
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
buildStalkerExternalPlaybackHeaders,
|
||||
executeStalkerRequest,
|
||||
getStalkerPortalOrigin,
|
||||
isCrossOriginStalkerStream,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
resolveStalkerPlaybackUrl,
|
||||
StalkerPortalRepairService,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
@@ -71,6 +73,7 @@ export class StreamResolverService {
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly portalRepair = inject(StalkerPortalRepairService);
|
||||
private readonly m3uEpgTimeoutMs = 3000;
|
||||
private readonly portalEpgTimeoutMs = 10000;
|
||||
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
|
||||
@@ -352,8 +355,18 @@ export class StreamResolverService {
|
||||
};
|
||||
|
||||
let response: StalkerCreateLinkResponse | undefined;
|
||||
if (playlist?.isFullStalkerPortal && playlist) {
|
||||
response = await this.stalkerSession.makeAuthenticatedRequest(
|
||||
// Items opened from global collections can carry their own portal
|
||||
// coordinates with no playlist row; only a playlist-backed request
|
||||
// can go through the shared mode routing + lazy portal repair. When
|
||||
// the row exists it wins over the item's snapshot of the portal URL
|
||||
// (a repaired endpoint must beat a stale favorite).
|
||||
if (playlist) {
|
||||
response = await executeStalkerRequest<StalkerCreateLinkResponse>(
|
||||
{
|
||||
dataService: this.dataService,
|
||||
stalkerSession: this.stalkerSession,
|
||||
portalRepair: this.portalRepair,
|
||||
},
|
||||
playlist,
|
||||
params
|
||||
);
|
||||
@@ -367,14 +380,23 @@ export class StreamResolverService {
|
||||
|
||||
const rawCmd = response?.js?.cmd ?? '';
|
||||
|
||||
// Re-read the override AFTER the request: a lazy repair may have
|
||||
// moved the endpoint during this very call, and both the relative
|
||||
// `js.cmd` resolution and the playback header origin must follow
|
||||
// the endpoint that actually answered.
|
||||
const effectivePortalUrl = playlist
|
||||
? (this.portalRepair.applyOverride(playlist).portalUrl ??
|
||||
portalUrl)
|
||||
: portalUrl;
|
||||
|
||||
return this.buildStalkerPlayback(item, playlist, {
|
||||
macAddress,
|
||||
portalUrl,
|
||||
portalUrl: effectivePortalUrl,
|
||||
// Shared normalizer from the Stalker store: strips the solution
|
||||
// prefix and resolves relative `/media/...` or `?...` responses
|
||||
// against the portal base instead of returning them verbatim.
|
||||
streamUrl: resolveStalkerPlaybackUrl(
|
||||
portalUrl,
|
||||
effectivePortalUrl,
|
||||
item.stalkerCmd ?? '',
|
||||
rawCmd
|
||||
),
|
||||
@@ -1005,19 +1027,16 @@ export class StreamResolverService {
|
||||
size: String(size),
|
||||
};
|
||||
|
||||
let response: StalkerEpgResponse;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
response = await this.stalkerSession.makeAuthenticatedRequest(
|
||||
const response: StalkerEpgResponse =
|
||||
await executeStalkerRequest<StalkerEpgResponse>(
|
||||
{
|
||||
dataService: this.dataService,
|
||||
stalkerSession: this.stalkerSession,
|
||||
portalRepair: this.portalRepair,
|
||||
},
|
||||
playlist,
|
||||
params
|
||||
);
|
||||
} else {
|
||||
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
const epgData = Array.isArray(response?.js)
|
||||
? response.js
|
||||
|
||||
@@ -4,6 +4,9 @@ export * from './lib/stalker-account-info.service';
|
||||
export * from './lib/stalker-content-types';
|
||||
export * from './lib/stalker-itv-cache.service';
|
||||
export * from './lib/stalker-live-playback.utils';
|
||||
export * from './lib/stalker-portal-discovery.service';
|
||||
export * from './lib/stalker-portal-discovery.utils';
|
||||
export * from './lib/stalker-portal-repair.service';
|
||||
export * from './lib/stalker-series.adapters';
|
||||
export * from './lib/stalker-session.service';
|
||||
export * from './lib/stalker-vod.utils';
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseStalkerDate,
|
||||
StalkerAccountInfoService,
|
||||
} from './stalker-account-info.service';
|
||||
import { StalkerPortalRepairService } from './stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
|
||||
describe('StalkerAccountInfoService', () => {
|
||||
@@ -15,6 +16,11 @@ describe('StalkerAccountInfoService', () => {
|
||||
refreshAccountProfile: jest.Mock;
|
||||
makeAuthenticatedRequest: jest.Mock;
|
||||
};
|
||||
let portalRepair: {
|
||||
applyOverride: jest.Mock;
|
||||
shouldAttemptRepair: jest.Mock;
|
||||
repairPortal: jest.Mock;
|
||||
};
|
||||
|
||||
const portalPlaylist = {
|
||||
_id: 'stalker-1',
|
||||
@@ -39,11 +45,20 @@ describe('StalkerAccountInfoService', () => {
|
||||
refreshAccountProfile: jest.fn(),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
};
|
||||
portalRepair = {
|
||||
applyOverride: jest.fn((playlist) => playlist),
|
||||
shouldAttemptRepair: jest.fn().mockReturnValue(false),
|
||||
repairPortal: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{ provide: StalkerSessionService, useValue: stalkerSession },
|
||||
{
|
||||
provide: StalkerPortalRepairService,
|
||||
useValue: portalRepair,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -86,6 +101,185 @@ describe('StalkerAccountInfoService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('repairs the portal and retries when the profile request hits a repair trigger', async () => {
|
||||
// The full-portal profile path bypasses executeStalkerRequest, so
|
||||
// opening the dialog on a playlist with a stale endpoint must be
|
||||
// able to repair it instead of just failing.
|
||||
const notFound = new Error('HTTP Error 404: Not Found');
|
||||
stalkerSession.refreshAccountProfile
|
||||
.mockRejectedValueOnce(notFound)
|
||||
.mockResolvedValueOnce({ login: 'user-1' });
|
||||
const repaired = {
|
||||
...fullPortalPlaylist,
|
||||
portalUrl: 'http://portal.example/stalker_portal/server/load.php',
|
||||
} as PlaylistMeta;
|
||||
portalRepair.shouldAttemptRepair.mockReturnValue(true);
|
||||
portalRepair.repairPortal.mockResolvedValue(repaired);
|
||||
|
||||
const snapshot = await service.fetchAccountInfo(fullPortalPlaylist);
|
||||
|
||||
expect(portalRepair.repairPortal).toHaveBeenCalledWith(
|
||||
fullPortalPlaylist
|
||||
);
|
||||
expect(
|
||||
stalkerSession.refreshAccountProfile
|
||||
).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ portalUrl: repaired.portalUrl })
|
||||
);
|
||||
expect(snapshot).toMatchObject({ login: 'user-1' });
|
||||
});
|
||||
|
||||
it('re-routes to get_main_info when the repair proves the portal is simple', async () => {
|
||||
// The playlist was wrongly marked full; discovery proves it is a
|
||||
// token-free panel, so retrying the handshake profile would fail
|
||||
// identically — the retry must use the simple-portal path.
|
||||
stalkerSession.refreshAccountProfile.mockRejectedValue(
|
||||
new Error('HTTP Error 404: Not Found')
|
||||
);
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { login: 'panel-user' },
|
||||
});
|
||||
portalRepair.shouldAttemptRepair.mockReturnValue(true);
|
||||
portalRepair.repairPortal.mockResolvedValue({
|
||||
...fullPortalPlaylist,
|
||||
portalUrl: 'http://portal.example/portal.php',
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta);
|
||||
|
||||
const snapshot = await service.fetchAccountInfo(fullPortalPlaylist);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
STALKER_REQUEST,
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ action: 'get_main_info' }),
|
||||
})
|
||||
);
|
||||
expect(snapshot).toMatchObject({ login: 'panel-user' });
|
||||
});
|
||||
|
||||
it('re-routes to the profile flow when a repair proves the portal is full', async () => {
|
||||
// Legacy row marked simple: get_main_info goes through
|
||||
// executeStalkerRequest, whose repair flips the mode to full and
|
||||
// retries the same (wrong) action. The dialog must then switch to
|
||||
// handshake + get_profile instead of showing nothing.
|
||||
dataService.sendIpcEvent.mockResolvedValue({ js: null });
|
||||
const repaired = {
|
||||
...portalPlaylist,
|
||||
portalUrl: 'http://portal.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
portalRepair.applyOverride
|
||||
.mockImplementationOnce((value: PlaylistMeta) => value)
|
||||
.mockImplementation(() => repaired);
|
||||
stalkerSession.refreshAccountProfile.mockResolvedValue({
|
||||
login: 'full-user',
|
||||
});
|
||||
|
||||
const snapshot = await service.fetchAccountInfo(portalPlaylist);
|
||||
|
||||
expect(stalkerSession.refreshAccountProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isFullStalkerPortal: true })
|
||||
);
|
||||
expect(snapshot).toMatchObject({ login: 'full-user' });
|
||||
});
|
||||
|
||||
it('prefers the profile flow over a PARTIAL main-info answer after a mode repair', async () => {
|
||||
// A bare login from get_main_info must not win over the profile
|
||||
// flow once the repair proved the portal is full — expiry and
|
||||
// tariff live only behind handshake + get_profile.
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { login: 'partial-user' },
|
||||
});
|
||||
const repaired = {
|
||||
...portalPlaylist,
|
||||
portalUrl: 'http://portal.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
portalRepair.applyOverride
|
||||
.mockImplementationOnce((value: PlaylistMeta) => value)
|
||||
.mockImplementationOnce((value: PlaylistMeta) => value)
|
||||
.mockImplementation(() => repaired);
|
||||
stalkerSession.refreshAccountProfile.mockResolvedValue({
|
||||
login: 'full-user',
|
||||
expire_date: '1795000000',
|
||||
tariff_plan_name: 'Premium',
|
||||
});
|
||||
|
||||
const snapshot = await service.fetchAccountInfo(portalPlaylist);
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
login: 'full-user',
|
||||
tariffPlanName: 'Premium',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the partial main-info facts when the profile flow publishes nothing', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { login: 'partial-user' },
|
||||
});
|
||||
const repaired = {
|
||||
...portalPlaylist,
|
||||
portalUrl: 'http://portal.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
// applyOverride runs twice before the repair lands (routing, then
|
||||
// inside executeStalkerRequest); only afterwards does it report the
|
||||
// repaired playlist.
|
||||
portalRepair.applyOverride
|
||||
.mockImplementationOnce((value: PlaylistMeta) => value)
|
||||
.mockImplementationOnce((value: PlaylistMeta) => value)
|
||||
.mockImplementation(() => repaired);
|
||||
stalkerSession.refreshAccountProfile.mockResolvedValue(undefined);
|
||||
|
||||
const snapshot = await service.fetchAccountInfo(portalPlaylist);
|
||||
|
||||
expect(snapshot).toMatchObject({ login: 'partial-user' });
|
||||
});
|
||||
|
||||
it('re-routes to the profile flow when the post-repair main-info retry rejects', async () => {
|
||||
// A full installation that does not implement get_main_info answers
|
||||
// the internal retry with 404 — the rejection must reach the
|
||||
// repaired-mode check instead of failing the dialog.
|
||||
dataService.sendIpcEvent.mockRejectedValue(
|
||||
new Error('HTTP Error 404: Not Found')
|
||||
);
|
||||
const repaired = {
|
||||
...portalPlaylist,
|
||||
portalUrl: 'http://portal.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
portalRepair.applyOverride
|
||||
.mockImplementationOnce((value: PlaylistMeta) => value)
|
||||
.mockImplementationOnce((value: PlaylistMeta) => value)
|
||||
.mockImplementation(() => repaired);
|
||||
stalkerSession.refreshAccountProfile.mockResolvedValue({
|
||||
login: 'full-user',
|
||||
});
|
||||
|
||||
const snapshot = await service.fetchAccountInfo(portalPlaylist);
|
||||
|
||||
expect(snapshot).toMatchObject({ login: 'full-user' });
|
||||
});
|
||||
|
||||
it('rethrows a main-info failure when no repair changed the mode', async () => {
|
||||
const boom = new Error('HTTP Error 404: Not Found');
|
||||
dataService.sendIpcEvent.mockRejectedValue(boom);
|
||||
|
||||
await expect(service.fetchAccountInfo(portalPlaylist)).rejects.toBe(
|
||||
boom
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows profile failures the repair declines to act on', async () => {
|
||||
const boom = new Error('timeout of 15000ms exceeded');
|
||||
stalkerSession.refreshAccountProfile.mockRejectedValue(boom);
|
||||
|
||||
await expect(
|
||||
service.fetchAccountInfo(fullPortalPlaylist)
|
||||
).rejects.toBe(boom);
|
||||
expect(portalRepair.repairPortal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when the full-portal profile has no account block', async () => {
|
||||
stalkerSession.refreshAccountProfile.mockResolvedValue(undefined);
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
isFullStalkerPortalPlaylist,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalRepairService } from './stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
import {
|
||||
executeStalkerRequest,
|
||||
@@ -63,6 +67,7 @@ interface StalkerMainInfoResponse {
|
||||
export class StalkerAccountInfoService {
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly portalRepair = inject(StalkerPortalRepairService);
|
||||
|
||||
async fetchAccountInfo(
|
||||
playlist: PlaylistMeta
|
||||
@@ -71,15 +76,82 @@ export class StalkerAccountInfoService {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isFullStalkerPortalPlaylist(playlist)) {
|
||||
return this.fetchViaProfile(playlist);
|
||||
const effectivePlaylist = this.portalRepair.applyOverride(playlist);
|
||||
if (isFullStalkerPortalPlaylist(effectivePlaylist)) {
|
||||
return this.fetchViaProfile(effectivePlaylist);
|
||||
}
|
||||
|
||||
return this.fetchViaMainInfo(playlist);
|
||||
// A REJECTED main-info call must reach the same repaired-mode check
|
||||
// as an empty or partial one: a repair can flip the portal to full
|
||||
// mid-request, and a full installation that does not implement
|
||||
// `get_main_info` answers the internal retry with 404.
|
||||
let snapshot: StalkerAccountSnapshot | null = null;
|
||||
let mainInfoError: unknown;
|
||||
try {
|
||||
snapshot = await this.fetchViaMainInfo(effectivePlaylist);
|
||||
} catch (error) {
|
||||
mainInfoError = error;
|
||||
}
|
||||
|
||||
// `fetchViaMainInfo` runs through executeStalkerRequest, whose lazy
|
||||
// repair retries the SAME action internally. If that repair proved
|
||||
// the portal is actually a full one, `get_main_info` was the wrong
|
||||
// call: canonical installations publish subscription details only
|
||||
// through handshake + get_profile, so even a PARTIAL main-info
|
||||
// answer (a bare login) must not win over the profile flow. Checked
|
||||
// before accepting the snapshot, symmetric with the full→simple
|
||||
// re-route in `fetchViaProfile`.
|
||||
const repairedPlaylist = this.portalRepair.applyOverride(playlist);
|
||||
if (
|
||||
isFullStalkerPortalPlaylist(repairedPlaylist) &&
|
||||
!isFullStalkerPortalPlaylist(effectivePlaylist)
|
||||
) {
|
||||
const profileSnapshot =
|
||||
await this.fetchViaProfile(repairedPlaylist);
|
||||
// Keep the partial main-info facts if the profile path itself
|
||||
// publishes nothing — losing data to the re-route would be
|
||||
// worse than the incomplete answer.
|
||||
return profileSnapshot ?? snapshot;
|
||||
}
|
||||
|
||||
// No mode change: a main-info failure is the caller's failure.
|
||||
if (mainInfoError !== undefined) {
|
||||
throw mainInfoError;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async fetchViaProfile(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<StalkerAccountSnapshot | null> {
|
||||
try {
|
||||
return await this.requestProfileSnapshot(playlist);
|
||||
} catch (error) {
|
||||
// The profile path does not go through executeStalkerRequest,
|
||||
// so wire the same lazy repair here: opening the account dialog
|
||||
// on a playlist with a stale endpoint must be able to fix it
|
||||
// instead of waiting for an unrelated catalog request.
|
||||
if (!this.portalRepair.shouldAttemptRepair(playlist, error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const repaired = await this.portalRepair.repairPortal(playlist);
|
||||
if (!repaired) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Re-enter the MODE routing: a repair can prove the portal is a
|
||||
// token-free panel, and retrying the handshake-based profile
|
||||
// against it would fail exactly the same way.
|
||||
return isFullStalkerPortalPlaylist(repaired)
|
||||
? this.requestProfileSnapshot(repaired)
|
||||
: this.fetchViaMainInfo(repaired);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestProfileSnapshot(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<StalkerAccountSnapshot | null> {
|
||||
// Goes through the session service rather than calling
|
||||
// authenticate() directly: it serializes with any in-flight
|
||||
@@ -109,6 +181,7 @@ export class StalkerAccountInfoService {
|
||||
{
|
||||
dataService: this.dataService,
|
||||
stalkerSession: this.stalkerSession,
|
||||
portalRepair: this.portalRepair,
|
||||
},
|
||||
playlist,
|
||||
{
|
||||
@@ -146,26 +219,10 @@ export class StalkerAccountInfoService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a playlist should use the full `/stalker_portal/` flow.
|
||||
*
|
||||
* The persisted flag is authoritative when present, but a playlist
|
||||
* restored from an older backup can carry `undefined` after the one-shot
|
||||
* metadata migration has already run — fall back to the same URL rule
|
||||
* that migration uses (`withExplicitLegacyStalkerPortalFlag` in
|
||||
* PlaylistsService) rather than mislabelling it as a legacy panel.
|
||||
*/
|
||||
export function isFullStalkerPortalPlaylist(playlist: PlaylistMeta): boolean {
|
||||
if (playlist.isFullStalkerPortal !== undefined) {
|
||||
return Boolean(playlist.isFullStalkerPortal);
|
||||
}
|
||||
|
||||
const portalUrl = playlist.portalUrl ?? playlist.url ?? '';
|
||||
return (
|
||||
portalUrl.includes('/stalker_portal') ||
|
||||
portalUrl.includes('/server/load.php')
|
||||
);
|
||||
}
|
||||
// The portal-mode predicate moved to `@iptvnator/shared/interfaces`
|
||||
// (stalker-portal-mode.util) so every consumer shares one rule; re-exported
|
||||
// here for existing importers.
|
||||
export { isFullStalkerPortalPlaylist };
|
||||
|
||||
function normalizeSnapshot(
|
||||
snapshot: StalkerAccountSnapshot
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
normalizeStalkerIdentityValue,
|
||||
normalizeStalkerPortalIdentity as normalizeSharedStalkerPortalIdentity,
|
||||
type Playlist,
|
||||
type StalkerPortalIdentity,
|
||||
@@ -13,6 +14,37 @@ export {
|
||||
type StalkerPortalIdentity,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Canonical fingerprint of WHO a portal session belongs to: the MAC plus
|
||||
* every Stalker identity field, trim-normalized so blank and absent values
|
||||
* are equivalent. The repair override, the once-per-config probe latch and
|
||||
* the session token cache are all keyed/validated with this — a session
|
||||
* negotiated for one fingerprint must never serve another.
|
||||
*/
|
||||
export function stalkerIdentityFingerprint(
|
||||
playlist: Pick<
|
||||
Playlist,
|
||||
| 'macAddress'
|
||||
| 'stalkerSerialNumber'
|
||||
| 'stalkerDeviceId1'
|
||||
| 'stalkerDeviceId2'
|
||||
| 'stalkerSignature1'
|
||||
| 'stalkerSignature2'
|
||||
>
|
||||
): string {
|
||||
// JSON-encoded, not delimiter-joined: identity values are unrestricted
|
||||
// strings, and an unescaped separator would let distinct tuples alias
|
||||
// each other and bypass the identity invalidation.
|
||||
return JSON.stringify([
|
||||
normalizeStalkerIdentityValue(playlist.macAddress) ?? '',
|
||||
normalizeStalkerIdentityValue(playlist.stalkerSerialNumber) ?? '',
|
||||
normalizeStalkerIdentityValue(playlist.stalkerDeviceId1) ?? '',
|
||||
normalizeStalkerIdentityValue(playlist.stalkerDeviceId2) ?? '',
|
||||
normalizeStalkerIdentityValue(playlist.stalkerSignature1) ?? '',
|
||||
normalizeStalkerIdentityValue(playlist.stalkerSignature2) ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
export function getStalkerPortalIdentityFromPlaylist(
|
||||
playlist: Pick<
|
||||
Playlist,
|
||||
|
||||
@@ -52,7 +52,9 @@ function pageOf(items: unknown[], totalItems: number, pageSize = 14) {
|
||||
|
||||
const UNSUPPORTED_ACTION = { js: { error: 'Unknown action: get_all_channels' } };
|
||||
|
||||
async function flushMicrotasks(times = 5): Promise<void> {
|
||||
// Depth 10: executeStalkerRequest routes through an extra async hop for
|
||||
// the portal-repair pipeline, so page transitions settle a tick later.
|
||||
async function flushMicrotasks(times = 10): Promise<void> {
|
||||
for (let index = 0; index < times; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
StalkerItvLoadProgress,
|
||||
loadFullItvChannelList,
|
||||
} from './stalker-itv-channel-loader';
|
||||
import { StalkerPortalRepairService } from './stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
import { StalkerRequestDeps } from './stores/utils';
|
||||
|
||||
@@ -35,6 +36,7 @@ export class StalkerItvCacheService {
|
||||
private readonly requestDeps: StalkerRequestDeps = {
|
||||
dataService: inject(DataService),
|
||||
stalkerSession: inject(StalkerSessionService),
|
||||
portalRepair: inject(StalkerPortalRepairService),
|
||||
};
|
||||
|
||||
/** Portal keys whose full channel list is loaded. */
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const MAC = '00:1A:79:AA:BB:CC';
|
||||
|
||||
describe('StalkerPortalDiscoveryService', () => {
|
||||
let service: StalkerPortalDiscoveryService;
|
||||
let sendIpcEvent: jest.Mock;
|
||||
let authenticate: jest.Mock;
|
||||
|
||||
/** Maps probed endpoint URL → resolved value or rejection. */
|
||||
function mockProbes(
|
||||
handlers: Record<string, { resolve?: unknown; reject?: unknown }>
|
||||
): void {
|
||||
sendIpcEvent.mockImplementation((_event, payload) => {
|
||||
const { url } = payload as { url: string };
|
||||
const handler = handlers[url];
|
||||
if (!handler) {
|
||||
return Promise.reject(
|
||||
new Error(`unexpected probe for ${url}`)
|
||||
);
|
||||
}
|
||||
if ('reject' in handler) {
|
||||
return Promise.reject(handler.reject);
|
||||
}
|
||||
return Promise.resolve(handler.resolve);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sendIpcEvent = jest.fn();
|
||||
authenticate = jest.fn();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: DataService, useValue: { sendIpcEvent } },
|
||||
{ provide: StalkerSessionService, useValue: { authenticate } },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(StalkerPortalDiscoveryService);
|
||||
});
|
||||
|
||||
it('resolves a tolerant portal.php panel as a simple portal without authenticating', async () => {
|
||||
mockProbes({
|
||||
'http://panel.example/portal.php': {
|
||||
resolve: { js: [{ id: '1', title: 'News' }] },
|
||||
},
|
||||
});
|
||||
|
||||
const outcome = await service.discover('http://panel.example/c', MAC);
|
||||
|
||||
expect(outcome).toEqual({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://panel.example/portal.php',
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
expect(authenticate).not.toHaveBeenCalled();
|
||||
// The winning candidate ends discovery — no further probes.
|
||||
expect(sendIpcEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls through a 404 portal.php to server/load.php and classifies by handshake', async () => {
|
||||
mockProbes({
|
||||
'http://ministra.example/portal.php': {
|
||||
reject: { message: 'HTTP Error: Not Found', status: 404 },
|
||||
},
|
||||
'http://ministra.example/server/load.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
});
|
||||
authenticate.mockResolvedValue({
|
||||
token: 'TOKEN1',
|
||||
accountInfo: { login: 'user-1' },
|
||||
});
|
||||
|
||||
const outcome = await service.discover(
|
||||
'http://ministra.example/c',
|
||||
MAC,
|
||||
{ serialNumber: 'SN1' }
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'TOKEN1',
|
||||
accountInfo: { login: 'user-1' },
|
||||
});
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
'http://ministra.example/server/load.php',
|
||||
MAC,
|
||||
{ serialNumber: 'SN1' }
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a pasted canonical URL in full mode without probing portal.php first', async () => {
|
||||
mockProbes({
|
||||
'http://ministra.example/server/load.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
});
|
||||
authenticate.mockResolvedValue({ token: 'TOKEN2' });
|
||||
|
||||
const outcome = await service.discover(
|
||||
'http://ministra.example/server/load.php',
|
||||
MAC
|
||||
);
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
expect(sendIpcEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('classifies a token-enforcing portal.php panel as a full portal', async () => {
|
||||
// Strict reseller panels exist; behavior beats the URL shape.
|
||||
mockProbes({
|
||||
'http://strict.example/portal.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
});
|
||||
authenticate.mockResolvedValue({ token: 'TOKEN3' });
|
||||
|
||||
const outcome = await service.discover('http://strict.example/c', MAC);
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://strict.example/portal.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports auth-rejected when an endpoint demands auth we cannot complete', async () => {
|
||||
mockProbes({
|
||||
'http://ministra.example/portal.php': {
|
||||
reject: { message: 'HTTP Error: Not Found', status: 404 },
|
||||
},
|
||||
'http://ministra.example/server/load.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
'http://ministra.example/stalker_portal/server/load.php': {
|
||||
reject: { message: 'HTTP Error: Not Found', status: 404 },
|
||||
},
|
||||
});
|
||||
authenticate.mockRejectedValue(new Error('Profile error: blocked'));
|
||||
|
||||
const outcome = await service.discover(
|
||||
'http://ministra.example/c',
|
||||
MAC
|
||||
);
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
status: 'auth-rejected',
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an HTTP 401/403 probe answer as auth-required, not endpoint-absent', async () => {
|
||||
// Non-standard middlewares answer 401 where the stock server sends
|
||||
// HTTP 200 + plain text; skipping the candidate would abort imports
|
||||
// for portals that previously authenticated directly.
|
||||
mockProbes({
|
||||
'http://gated.example/portal.php': {
|
||||
reject: { message: 'HTTP Error 401: Unauthorized', status: 401 },
|
||||
},
|
||||
});
|
||||
authenticate.mockResolvedValue({ token: 'TOKEN4' });
|
||||
|
||||
const outcome = await service.discover('http://gated.example/c', MAC);
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://gated.example/portal.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
expect(authenticate).toHaveBeenCalledWith(
|
||||
'http://gated.example/portal.php',
|
||||
MAC,
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('records a 401 candidate as auth-rejected when the handshake is refused', async () => {
|
||||
mockProbes({
|
||||
'http://gated.example/portal.php': {
|
||||
reject: { message: 'HTTP Error 403: Forbidden', status: 403 },
|
||||
},
|
||||
'http://gated.example/server/load.php': {
|
||||
reject: { message: 'HTTP Error 404: Not Found', status: 404 },
|
||||
},
|
||||
'http://gated.example/stalker_portal/server/load.php': {
|
||||
reject: { message: 'HTTP Error 404: Not Found', status: 404 },
|
||||
},
|
||||
});
|
||||
authenticate.mockRejectedValue(new Error('Handshake failed: No token received'));
|
||||
|
||||
const outcome = await service.discover('http://gated.example/c', MAC);
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
status: 'auth-rejected',
|
||||
portalUrl: 'http://gated.example/portal.php',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a candidate whose get_profile answers a structured denial', async () => {
|
||||
// The handshake can hand out a token whose profile call still
|
||||
// denies; accepting it would persist an unusable endpoint and skip
|
||||
// the healthy sibling.
|
||||
mockProbes({
|
||||
'http://mixed.example/portal.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
'http://mixed.example/server/load.php': {
|
||||
resolve: { js: [{ id: '1' }] },
|
||||
},
|
||||
});
|
||||
authenticate.mockResolvedValue({
|
||||
token: 'TOKEN-BAD',
|
||||
profileResponse: { js: { error: 'Invalid token' } },
|
||||
});
|
||||
|
||||
const outcome = await service.discover('http://mixed.example/c', MAC);
|
||||
|
||||
// Discovery moved on and resolved the healthy sibling instead.
|
||||
expect(outcome).toEqual({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://mixed.example/server/load.php',
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports unreachable when every candidate 404s', async () => {
|
||||
mockProbes({
|
||||
'http://empty.example/portal.php': {
|
||||
reject: { message: 'HTTP Error: Not Found', status: 404 },
|
||||
},
|
||||
'http://empty.example/server/load.php': {
|
||||
reject: { message: 'HTTP Error: Not Found', status: 404 },
|
||||
},
|
||||
'http://empty.example/stalker_portal/server/load.php': {
|
||||
reject: { message: 'HTTP Error: Not Found', status: 404 },
|
||||
},
|
||||
});
|
||||
|
||||
const outcome = await service.discover('http://empty.example/c', MAC);
|
||||
|
||||
expect(outcome).toEqual({ status: 'unreachable' });
|
||||
});
|
||||
|
||||
it('stops probing after a network-level failure — all candidates share the host', async () => {
|
||||
// Post-IPC, a network failure carries no resolvable HTTP status:
|
||||
// ipcRenderer strips the object shape and the message has no
|
||||
// "HTTP Error NNN" marker.
|
||||
mockProbes({
|
||||
'http://down.example/portal.php': {
|
||||
reject: new Error(
|
||||
"Error invoking remote method 'STALKER_REQUEST': connect ECONNREFUSED"
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const outcome = await service.discover('http://down.example/c', MAC);
|
||||
|
||||
expect(outcome).toEqual({ status: 'unreachable' });
|
||||
expect(sendIpcEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps probing past a candidate timeout — one handler can hang while siblings work', async () => {
|
||||
mockProbes({
|
||||
'http://slow.example/portal.php': {
|
||||
reject: new Error(
|
||||
"Error invoking remote method 'STALKER_REQUEST': timeout of 15000ms exceeded"
|
||||
),
|
||||
},
|
||||
'http://slow.example/server/load.php': {
|
||||
resolve: { js: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const outcome = await service.discover('http://slow.example/c', MAC);
|
||||
|
||||
expect(outcome).toEqual({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://slow.example/server/load.php',
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps probing past an endpoint-specific 5xx — the host answered', async () => {
|
||||
// One broken handler (a dead portal.php returning 500) must not
|
||||
// hide a healthy sibling endpoint on the same host.
|
||||
mockProbes({
|
||||
'http://flaky.example/portal.php': {
|
||||
reject: {
|
||||
message: 'HTTP Error 500: Internal Server Error',
|
||||
status: 500,
|
||||
},
|
||||
},
|
||||
'http://flaky.example/server/load.php': {
|
||||
resolve: 'Authorization failed.',
|
||||
},
|
||||
});
|
||||
authenticate.mockResolvedValue({ token: 'TOKEN5' });
|
||||
|
||||
const outcome = await service.discover('http://flaky.example/c', MAC);
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://flaky.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips endpoints that answer with something that is not a portal', async () => {
|
||||
mockProbes({
|
||||
'http://mixed.example/portal.php': {
|
||||
resolve: '<html>It works!</html>',
|
||||
},
|
||||
'http://mixed.example/server/load.php': {
|
||||
resolve: { js: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const outcome = await service.discover('http://mixed.example/c', MAC);
|
||||
|
||||
expect(outcome).toEqual({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://mixed.example/server/load.php',
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerProfileResponse,
|
||||
StalkerSessionService,
|
||||
} from './stalker-session.service';
|
||||
import { type StalkerPortalIdentity } from './stalker-identity.utils';
|
||||
import {
|
||||
buildStalkerEndpointCandidates,
|
||||
classifyStalkerProbeResponse,
|
||||
getStalkerRequestErrorStatus,
|
||||
isStalkerAuthFailureResponse,
|
||||
isStalkerProbeTimeout,
|
||||
} from './stalker-portal-discovery.utils';
|
||||
|
||||
/** A candidate endpoint answered and its auth behavior was observed. */
|
||||
export interface StalkerPortalEndpointResolution {
|
||||
status: 'resolved';
|
||||
/** The endpoint that actually answered content requests. */
|
||||
portalUrl: string;
|
||||
/** Observed behavior: true when the endpoint enforces the Bearer token. */
|
||||
isFullStalkerPortal: boolean;
|
||||
/** Session token from the classification handshake (full portals only). */
|
||||
token?: string;
|
||||
/** Account block from the classification `get_profile` (full portals only). */
|
||||
accountInfo?: StalkerProfileResponse['js']['account_info'];
|
||||
}
|
||||
|
||||
/**
|
||||
* An endpoint exists and demands authentication, but the handshake/profile
|
||||
* flow was refused — wrong MAC, blocked account, or a panel we cannot
|
||||
* authenticate against. Nothing may be persisted from this outcome.
|
||||
*/
|
||||
export interface StalkerPortalDiscoveryRejection {
|
||||
status: 'auth-rejected';
|
||||
portalUrl: string;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
/** No candidate answered like a Stalker portal (host down or not a portal). */
|
||||
export interface StalkerPortalDiscoveryUnreachable {
|
||||
status: 'unreachable';
|
||||
}
|
||||
|
||||
export type StalkerPortalDiscoveryOutcome =
|
||||
| StalkerPortalEndpointResolution
|
||||
| StalkerPortalDiscoveryRejection
|
||||
| StalkerPortalDiscoveryUnreachable;
|
||||
|
||||
/** 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;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('Stalker portal probe timed out')),
|
||||
timeoutMs
|
||||
);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which API endpoint a Stalker portal actually answers on and
|
||||
* whether it enforces the full auth lifecycle — by probing, not by URL
|
||||
* shape. Used at import time and by the lazy repair of previously
|
||||
* misclassified playlists.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StalkerPortalDiscoveryService {
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly logger = createLogger('StalkerPortalDiscovery');
|
||||
|
||||
/**
|
||||
* Probes candidate endpoints in order and classifies the first one that
|
||||
* answers. Per candidate: a token-less content request that returns real
|
||||
* data proves a token-free panel; the middleware's plain-text auth
|
||||
* failure proves the endpoint exists and enforces the token, which is
|
||||
* then confirmed by attempting the real handshake + `get_profile` flow.
|
||||
*/
|
||||
async discover(
|
||||
rawUrl: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity = {}
|
||||
): Promise<StalkerPortalDiscoveryOutcome> {
|
||||
const candidates = buildStalkerEndpointCandidates(rawUrl);
|
||||
let authRejection: StalkerPortalDiscoveryRejection | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
let probeResponse: unknown;
|
||||
try {
|
||||
probeResponse = await this.probeContent(candidate, macAddress);
|
||||
} catch (error) {
|
||||
const status = getStalkerRequestErrorStatus(error);
|
||||
if (status === 401 || status === 403) {
|
||||
// The endpoint exists but sits behind an HTTP auth gate —
|
||||
// non-standard middlewares answer 401/403 where the stock
|
||||
// server answers 200 + plain text. Attempt the real
|
||||
// handshake instead of skipping a valid candidate.
|
||||
const outcome = await this.confirmFullPortal(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity
|
||||
);
|
||||
if (outcome.status === 'resolved') {
|
||||
return outcome;
|
||||
}
|
||||
authRejection = authRejection ?? outcome;
|
||||
continue;
|
||||
}
|
||||
if (status !== undefined) {
|
||||
// Any resolvable HTTP status proves the HOST answered:
|
||||
// 4xx means this endpoint is absent, and a 5xx here can
|
||||
// be one broken handler (a dead /portal.php) while a
|
||||
// sibling candidate works — keep probing either way.
|
||||
continue;
|
||||
}
|
||||
if (isStalkerProbeTimeout(error)) {
|
||||
// A timeout can also be one hanging handler with healthy
|
||||
// siblings; each further candidate stays bounded by its
|
||||
// own probe budget.
|
||||
this.logger.warn(
|
||||
'Stalker portal probe timed out; trying the next candidate'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Connection-level failure (refused, unresolvable host):
|
||||
// every candidate lives on the same host, so further probing
|
||||
// cannot succeed either.
|
||||
this.logger.warn(
|
||||
'Stalker portal probe failed at network level; stopping discovery'
|
||||
);
|
||||
return authRejection ?? { status: 'unreachable' };
|
||||
}
|
||||
|
||||
switch (classifyStalkerProbeResponse(probeResponse)) {
|
||||
case 'data':
|
||||
return {
|
||||
status: 'resolved',
|
||||
portalUrl: candidate,
|
||||
isFullStalkerPortal: false,
|
||||
};
|
||||
case 'auth-required': {
|
||||
const outcome = await this.confirmFullPortal(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity
|
||||
);
|
||||
if (outcome.status === 'resolved') {
|
||||
return outcome;
|
||||
}
|
||||
// The endpoint is real but refused our credentials;
|
||||
// remember the first such endpoint in case no later
|
||||
// candidate resolves.
|
||||
authRejection = authRejection ?? outcome;
|
||||
continue;
|
||||
}
|
||||
case 'not-a-portal':
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return authRejection ?? { status: 'unreachable' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a token-enforcing endpoint by running the real handshake +
|
||||
* `get_profile` flow against it.
|
||||
*/
|
||||
private async confirmFullPortal(
|
||||
candidate: string,
|
||||
macAddress: string,
|
||||
identity: StalkerPortalIdentity
|
||||
): Promise<
|
||||
StalkerPortalEndpointResolution | StalkerPortalDiscoveryRejection
|
||||
> {
|
||||
try {
|
||||
const auth = await withTimeout(
|
||||
this.stalkerSession.authenticate(
|
||||
candidate,
|
||||
macAddress,
|
||||
identity
|
||||
),
|
||||
AUTH_TIMEOUT_MS
|
||||
);
|
||||
// A handshake can hand out a token whose `get_profile` still
|
||||
// answers a structured denial (`{js:{error:'Invalid token'}}`);
|
||||
// `authenticate()` only inspects `msg`/`block_msg`, so reporting
|
||||
// `resolved` here would persist an unusable endpoint and stop
|
||||
// before a healthy sibling is probed.
|
||||
if (isStalkerAuthFailureResponse(auth.profileResponse)) {
|
||||
return {
|
||||
status: 'auth-rejected',
|
||||
portalUrl: candidate,
|
||||
error: auth.profileResponse,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'resolved',
|
||||
portalUrl: candidate,
|
||||
isFullStalkerPortal: true,
|
||||
token: auth.token,
|
||||
accountInfo: auth.accountInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'auth-rejected',
|
||||
portalUrl: candidate,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-less, read-only content request (`itv/get_genres`) — the
|
||||
* cheapest action every Stalker-compatible panel implements and the
|
||||
* canonical middleware gates behind the Bearer token.
|
||||
*/
|
||||
private probeContent(url: string, macAddress: string): Promise<unknown> {
|
||||
return withTimeout(
|
||||
Promise.resolve(
|
||||
this.dataService.sendIpcEvent<unknown>(STALKER_REQUEST, {
|
||||
url,
|
||||
macAddress,
|
||||
params: {
|
||||
type: 'itv',
|
||||
action: 'get_genres',
|
||||
JsHttpRequest: '1-xml',
|
||||
},
|
||||
// Probing absent endpoints fails BY DESIGN — the
|
||||
// transport services skip their error snackbar for us.
|
||||
silent: true,
|
||||
})
|
||||
),
|
||||
PROBE_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
buildStalkerEndpointCandidates,
|
||||
classifyStalkerProbeResponse,
|
||||
getStalkerRequestErrorStatus,
|
||||
isStalkerAuthFailureBody,
|
||||
isStalkerAuthFailureMessage,
|
||||
isStalkerAuthFailureResponse,
|
||||
legacyTransformStalkerPortalUrl,
|
||||
normalizeStalkerPortalInputUrl,
|
||||
} from './stalker-portal-discovery.utils';
|
||||
|
||||
describe('buildStalkerEndpointCandidates', () => {
|
||||
it('probes the standard order for a /c URL users copy from the browser', () => {
|
||||
expect(
|
||||
buildStalkerEndpointCandidates('http://portal.example/c')
|
||||
).toEqual([
|
||||
'http://portal.example/portal.php',
|
||||
'http://portal.example/server/load.php',
|
||||
'http://portal.example/stalker_portal/server/load.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it('probes the same order for a bare host', () => {
|
||||
expect(buildStalkerEndpointCandidates('http://portal.example')).toEqual(
|
||||
[
|
||||
'http://portal.example/portal.php',
|
||||
'http://portal.example/server/load.php',
|
||||
'http://portal.example/stalker_portal/server/load.php',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('strips trailing slashes before deriving candidates', () => {
|
||||
expect(
|
||||
buildStalkerEndpointCandidates('http://portal.example/c///')
|
||||
).toEqual([
|
||||
'http://portal.example/portal.php',
|
||||
'http://portal.example/server/load.php',
|
||||
'http://portal.example/stalker_portal/server/load.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives an explicitly pasted .php endpoint the first shot', () => {
|
||||
expect(
|
||||
buildStalkerEndpointCandidates(
|
||||
'http://portal.example/server/load.php'
|
||||
)
|
||||
).toEqual([
|
||||
'http://portal.example/server/load.php',
|
||||
'http://portal.example/portal.php',
|
||||
'http://portal.example/stalker_portal/server/load.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a nonstandard pasted endpoint as the first candidate', () => {
|
||||
expect(
|
||||
buildStalkerEndpointCandidates('http://portal.example/cp/portal.php')
|
||||
).toEqual([
|
||||
'http://portal.example/cp/portal.php',
|
||||
'http://portal.example/cp/server/load.php',
|
||||
'http://portal.example/cp/stalker_portal/server/load.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it('derives standard fallbacks from a nonstandard endpoint\'s directory', () => {
|
||||
// The pasted endpoint keeps the first shot, but recovery candidates
|
||||
// must be its SIBLINGS — not paths appended to the file itself.
|
||||
expect(
|
||||
buildStalkerEndpointCandidates('http://portal.example/cp/api.php')
|
||||
).toEqual([
|
||||
'http://portal.example/cp/api.php',
|
||||
'http://portal.example/cp/portal.php',
|
||||
'http://portal.example/cp/server/load.php',
|
||||
'http://portal.example/cp/stalker_portal/server/load.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a real installation directory named c', () => {
|
||||
// `/tenant/c/portal.php` means the installation lives in `/tenant/c`
|
||||
// — the `/c` landing-page rewrite must not strip it, or the
|
||||
// siblings would be probed one level too high.
|
||||
expect(
|
||||
buildStalkerEndpointCandidates(
|
||||
'http://portal.example/tenant/c/portal.php'
|
||||
)
|
||||
).toEqual([
|
||||
'http://portal.example/tenant/c/portal.php',
|
||||
'http://portal.example/tenant/c/server/load.php',
|
||||
'http://portal.example/tenant/c/stalker_portal/server/load.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never nests stalker_portal twice for a /stalker_portal/c URL', () => {
|
||||
expect(
|
||||
buildStalkerEndpointCandidates(
|
||||
'http://portal.example/stalker_portal/c'
|
||||
)
|
||||
).toEqual([
|
||||
'http://portal.example/stalker_portal/portal.php',
|
||||
'http://portal.example/stalker_portal/server/load.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates the pasted canonical stalker_portal endpoint', () => {
|
||||
expect(
|
||||
buildStalkerEndpointCandidates(
|
||||
'http://portal.example/stalker_portal/server/load.php'
|
||||
)
|
||||
).toEqual([
|
||||
'http://portal.example/stalker_portal/server/load.php',
|
||||
'http://portal.example/stalker_portal/portal.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it('derives candidates from the pathname when the URL carries a query or fragment', () => {
|
||||
// Suffix matching on the raw string would keep `/c` and append the
|
||||
// endpoints inside the query — every probe would still hit /c.
|
||||
expect(
|
||||
buildStalkerEndpointCandidates('http://portal.example/c?key=value')
|
||||
).toEqual([
|
||||
'http://portal.example/portal.php',
|
||||
'http://portal.example/server/load.php',
|
||||
'http://portal.example/stalker_portal/server/load.php',
|
||||
]);
|
||||
expect(
|
||||
buildStalkerEndpointCandidates(
|
||||
'http://portal.example:8080/portal.php?sn=1#frag'
|
||||
)
|
||||
).toEqual([
|
||||
'http://portal.example:8080/portal.php',
|
||||
'http://portal.example:8080/server/load.php',
|
||||
'http://portal.example:8080/stalker_portal/server/load.php',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no candidates for empty or unparseable URLs', () => {
|
||||
expect(buildStalkerEndpointCandidates(' ')).toEqual([]);
|
||||
expect(buildStalkerEndpointCandidates('not-a-url')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeStalkerPortalInputUrl', () => {
|
||||
it('reduces a URL to origin + pathname', () => {
|
||||
expect(
|
||||
normalizeStalkerPortalInputUrl('http://host.example/c?key=value#f')
|
||||
).toBe('http://host.example/c');
|
||||
expect(
|
||||
normalizeStalkerPortalInputUrl(' http://host.example:8080/c/ ')
|
||||
).toBe('http://host.example:8080/c');
|
||||
});
|
||||
|
||||
it('feeds the legacy fallback transform a rewritable path', () => {
|
||||
// The offline-import fallback runs the legacy /c → portal.php
|
||||
// rewrite on this form; unnormalized input would keep the /c page.
|
||||
expect(
|
||||
legacyTransformStalkerPortalUrl(
|
||||
normalizeStalkerPortalInputUrl(
|
||||
'http://host.example/c?key=value'
|
||||
) ?? ''
|
||||
)
|
||||
).toBe('http://host.example/portal.php');
|
||||
});
|
||||
|
||||
it('returns null for unparseable input', () => {
|
||||
expect(normalizeStalkerPortalInputUrl('not-a-url')).toBeNull();
|
||||
expect(normalizeStalkerPortalInputUrl(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves the accepted URL authority instead of rebuilding from origin', () => {
|
||||
// Basic-auth credentials must not be silently dropped…
|
||||
expect(
|
||||
normalizeStalkerPortalInputUrl(
|
||||
'https://user:pass@host.example/c?key=value'
|
||||
)
|
||||
).toBe('https://user:pass@host.example/c');
|
||||
// …and file: URLs (origin "null") must stay parseable rather than
|
||||
// becoming "null/tmp/c" and throwing in the candidate builder.
|
||||
expect(normalizeStalkerPortalInputUrl('file:///tmp/c')).toBe(
|
||||
'file:///tmp/c'
|
||||
);
|
||||
expect(buildStalkerEndpointCandidates('file:///tmp/c')).toEqual([
|
||||
'file:///tmp/portal.php',
|
||||
'file:///tmp/server/load.php',
|
||||
'file:///tmp/stalker_portal/server/load.php',
|
||||
]);
|
||||
expect(
|
||||
buildStalkerEndpointCandidates('https://user:pass@host.example/c')
|
||||
).toEqual([
|
||||
'https://user:pass@host.example/portal.php',
|
||||
'https://user:pass@host.example/server/load.php',
|
||||
'https://user:pass@host.example/stalker_portal/server/load.php',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStalkerAuthFailureBody', () => {
|
||||
it.each([
|
||||
'Authorization failed.',
|
||||
'Authorization failed. 75',
|
||||
'Access denied.',
|
||||
'Unauthorized request.',
|
||||
' Authorization failed. ',
|
||||
])('recognizes the middleware body %j', (body) => {
|
||||
expect(isStalkerAuthFailureBody(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects long HTML pages that merely mention the phrase', () => {
|
||||
const page = `<html><head><title>Site</title></head><body>${'x'.repeat(
|
||||
300
|
||||
)} access denied ${'y'.repeat(100)}</body></html>`;
|
||||
expect(isStalkerAuthFailureBody(page)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-string and empty responses', () => {
|
||||
expect(isStalkerAuthFailureBody({ js: [] })).toBe(false);
|
||||
expect(isStalkerAuthFailureBody(undefined)).toBe(false);
|
||||
expect(isStalkerAuthFailureBody(null)).toBe(false);
|
||||
expect(isStalkerAuthFailureBody('')).toBe(false);
|
||||
expect(isStalkerAuthFailureBody('OK')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStalkerAuthFailureResponse', () => {
|
||||
it('recognizes both the plain-text body and the JSON envelope forms', () => {
|
||||
expect(isStalkerAuthFailureResponse('Authorization failed.')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isStalkerAuthFailureResponse({
|
||||
js: { error: 'Authorization failed' },
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStalkerAuthFailureResponse({ js: { msg: 'Access denied.' } })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes the wider structured-field phrases the session service accepts', () => {
|
||||
expect(
|
||||
isStalkerAuthFailureResponse({ js: { error: 'Invalid token' } })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStalkerAuthFailureResponse({ js: { error: 'Auth failed' } })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStalkerAuthFailureResponse({ js: { msg: 'unauthorized' } })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag ordinary data or unrelated js errors', () => {
|
||||
expect(isStalkerAuthFailureResponse({ js: { data: [] } })).toBe(false);
|
||||
expect(
|
||||
isStalkerAuthFailureResponse({
|
||||
js: { error: 'Unknown action: get_genres' },
|
||||
})
|
||||
).toBe(false);
|
||||
expect(isStalkerAuthFailureResponse(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStalkerAuthFailureMessage', () => {
|
||||
it('matches the wide phrase set our own auth layer produces', () => {
|
||||
expect(
|
||||
isStalkerAuthFailureMessage('Profile error: Invalid token')
|
||||
).toBe(true);
|
||||
expect(isStalkerAuthFailureMessage('Profile error: Auth failed')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isStalkerAuthFailureMessage('Profile error: Access denied.')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('stays narrower than a free-form body check for unrelated text', () => {
|
||||
expect(isStalkerAuthFailureMessage('nothing_to_play')).toBe(false);
|
||||
expect(isStalkerAuthFailureMessage('timeout of 15000ms exceeded')).toBe(
|
||||
false
|
||||
);
|
||||
expect(isStalkerAuthFailureMessage(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('is wider than the plain-text BODY matcher on purpose', () => {
|
||||
// A portal BODY saying "Invalid token" is not one of the three
|
||||
// middleware phrases; a controlled Error message is.
|
||||
expect(isStalkerAuthFailureBody('Invalid token')).toBe(false);
|
||||
expect(isStalkerAuthFailureMessage('Invalid token')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyStalkerProbeResponse', () => {
|
||||
it('classifies a js envelope as data', () => {
|
||||
expect(classifyStalkerProbeResponse({ js: [] })).toBe('data');
|
||||
expect(classifyStalkerProbeResponse({ js: { data: [] } })).toBe('data');
|
||||
});
|
||||
|
||||
it('classifies the plain-text auth failure as auth-required', () => {
|
||||
expect(classifyStalkerProbeResponse('Authorization failed.')).toBe(
|
||||
'auth-required'
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies the JSON-envelope auth failure as auth-required, not data', () => {
|
||||
// Some panels answer HTTP 200 + {js:{error:"Authorization failed"}}
|
||||
// instead of the plain-text body; treating it as data would persist
|
||||
// the portal as token-free with no repair trigger ever firing.
|
||||
expect(
|
||||
classifyStalkerProbeResponse({
|
||||
js: { error: 'Authorization failed' },
|
||||
})
|
||||
).toBe('auth-required');
|
||||
});
|
||||
|
||||
it('requires a real get_genres data shape, not a bare js key', () => {
|
||||
// A 200 error envelope must not end discovery on a broken
|
||||
// candidate — the healthy sibling would never be probed.
|
||||
expect(
|
||||
classifyStalkerProbeResponse({ js: { error: 'Unknown action' } })
|
||||
).toBe('not-a-portal');
|
||||
expect(classifyStalkerProbeResponse({ js: false })).toBe(
|
||||
'not-a-portal'
|
||||
);
|
||||
expect(classifyStalkerProbeResponse({ js: null })).toBe(
|
||||
'not-a-portal'
|
||||
);
|
||||
expect(classifyStalkerProbeResponse({ js: {} })).toBe('not-a-portal');
|
||||
});
|
||||
|
||||
it('classifies anything else as not-a-portal', () => {
|
||||
expect(classifyStalkerProbeResponse('<html>welcome</html>')).toBe(
|
||||
'not-a-portal'
|
||||
);
|
||||
expect(classifyStalkerProbeResponse(undefined)).toBe('not-a-portal');
|
||||
expect(classifyStalkerProbeResponse({ payload: 1 })).toBe(
|
||||
'not-a-portal'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStalkerRequestErrorStatus', () => {
|
||||
it('reads the status the Electron transport throws for HTTP errors', () => {
|
||||
expect(
|
||||
getStalkerRequestErrorStatus({
|
||||
message: 'HTTP Error 404: Not Found',
|
||||
status: 404,
|
||||
})
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('parses the status out of the IPC-wrapped message — invoke strips custom properties', () => {
|
||||
expect(
|
||||
getStalkerRequestErrorStatus(
|
||||
new Error(
|
||||
"Error invoking remote method 'STALKER_REQUEST': HTTP Error 404: Not Found"
|
||||
)
|
||||
)
|
||||
).toBe(404);
|
||||
// HTTP/2 has no reason phrases; the code alone must be enough.
|
||||
expect(
|
||||
getStalkerRequestErrorStatus(new Error('HTTP Error 404: '))
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('returns undefined for plain errors', () => {
|
||||
expect(getStalkerRequestErrorStatus(new Error('boom'))).toBeUndefined();
|
||||
expect(getStalkerRequestErrorStatus(undefined)).toBeUndefined();
|
||||
expect(getStalkerRequestErrorStatus({ status: '404' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacyTransformStalkerPortalUrl', () => {
|
||||
it('keeps the historical rewrites for the unreachable-host fallback', () => {
|
||||
expect(legacyTransformStalkerPortalUrl('http://x.example/c')).toBe(
|
||||
'http://x.example/portal.php'
|
||||
);
|
||||
expect(
|
||||
legacyTransformStalkerPortalUrl('http://x.example/stalker_portal/c')
|
||||
).toBe('http://x.example/stalker_portal/server/load.php');
|
||||
expect(
|
||||
legacyTransformStalkerPortalUrl('http://x.example/stalker_portal')
|
||||
).toBe('http://x.example/stalker_portal/server/load.php');
|
||||
expect(
|
||||
legacyTransformStalkerPortalUrl('http://x.example/portal.php')
|
||||
).toBe('http://x.example/portal.php');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Pure helpers for Stalker portal endpoint discovery.
|
||||
*
|
||||
* The portal API endpoint cannot be derived reliably from the URL a user
|
||||
* pastes: `portal.php` is a reseller-panel alias that official
|
||||
* Stalker/Ministra never serves, while genuine installations answer at
|
||||
* `<base>/server/load.php` (optionally under `/stalker_portal`). Discovery
|
||||
* therefore probes concrete candidates and classifies each endpoint by how
|
||||
* it responds, instead of guessing from the URL shape (#850, #686, #755).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Candidate API endpoints for a pasted portal URL, in probe order.
|
||||
*
|
||||
* An explicit `.php` endpoint pasted by the user (or persisted by a previous
|
||||
* import) always gets the first shot — nonstandard panel paths exist in the
|
||||
* wild and must not lose to the standard candidates. After that the order is
|
||||
* `portal.php` → `server/load.php` → `stalker_portal/server/load.php`, so
|
||||
* reseller panels resolve exactly as they did before discovery existed.
|
||||
*/
|
||||
/**
|
||||
* Reduces a pasted portal URL to `origin + pathname` (no query, no
|
||||
* fragment, no trailing slashes). Suffix logic anywhere in discovery must
|
||||
* run on this form: string-suffix matching on the raw URL would bolt
|
||||
* endpoint rewrites onto the query instead of the path. Returns null for
|
||||
* input the URL parser rejects.
|
||||
*/
|
||||
export function normalizeStalkerPortalInputUrl(rawUrl: string): string | null {
|
||||
const trimmed = rawUrl.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Mutate the parsed URL instead of rebuilding from `origin`:
|
||||
// origin-reconstruction destroys authority information the import
|
||||
// validator accepts — file: URLs have origin "null" and basic-auth
|
||||
// credentials (user:pass@host) would be silently dropped.
|
||||
const parsed = new URL(trimmed);
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
|
||||
return parsed.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStalkerEndpointCandidates(rawUrl: string): string[] {
|
||||
// Queries and fragments are dropped from every candidate — the Stalker
|
||||
// API endpoints take their parameters per request, and a stored query
|
||||
// would collide with the transport's own query building.
|
||||
const normalized = normalizeStalkerPortalInputUrl(rawUrl);
|
||||
if (normalized === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = new URL(normalized);
|
||||
const path = parsed.pathname.replace(/\/+$/, '');
|
||||
// Candidates swap only the PATH: scheme, credentials, host and port of
|
||||
// the accepted URL are preserved verbatim.
|
||||
const candidateFrom = (candidatePath: string): string => {
|
||||
const candidate = new URL(parsed.href);
|
||||
candidate.pathname = candidatePath;
|
||||
return candidate.href;
|
||||
};
|
||||
|
||||
const candidates: string[] = [];
|
||||
if (/\.php$/i.test(path)) {
|
||||
candidates.push(candidateFrom(path));
|
||||
}
|
||||
|
||||
const base = /\.php$/i.test(path)
|
||||
? // An endpoint file was pasted: strip only the FILE part. The `/c`
|
||||
// landing-page rewrite must not run here — a real installation
|
||||
// directory named `c` (`/tenant/c/portal.php`) would otherwise be
|
||||
// stripped too and the siblings probed one level too high.
|
||||
path
|
||||
.replace(/\/portal\.php$/i, '')
|
||||
.replace(/\/server\/load\.php$/i, '')
|
||||
// A nonstandard pasted endpoint (…/cp/api.php) keeps its
|
||||
// first-shot candidate above, but the standard fallbacks must
|
||||
// be its SIBLINGS — the directory, not the file.
|
||||
.replace(/\/[^/]*\.php$/i, '')
|
||||
: // The `/c` landing page users copy from the browser is not part
|
||||
// of the API path.
|
||||
path.replace(/\/c$/i, '');
|
||||
|
||||
candidates.push(candidateFrom(`${base}/portal.php`));
|
||||
candidates.push(candidateFrom(`${base}/server/load.php`));
|
||||
// `<base>/server/load.php` already IS the canonical form when the base
|
||||
// ends in /stalker_portal — nesting it again would probe a path no
|
||||
// server has.
|
||||
if (!/\/stalker_portal(\/|$)/i.test(base)) {
|
||||
candidates.push(candidateFrom(`${base}/stalker_portal/server/load.php`));
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The stock Stalker middleware answers auth failures with HTTP 200 and a
|
||||
* bare plain-text body — never a 401/403. These are the three exact strings
|
||||
* it emits (sometimes with a trailing numeric counter).
|
||||
*/
|
||||
const STALKER_AUTH_FAILURE_PATTERNS = [
|
||||
/authorization\s+failed/i,
|
||||
/access\s+denied/i,
|
||||
/unauthorized\s+request/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether a portal response body is one of the middleware's plain-text auth
|
||||
* failures. The length cap keeps an arbitrary HTML error page that merely
|
||||
* mentions "access denied" from being mistaken for the middleware's bare
|
||||
* phrase.
|
||||
*/
|
||||
export function isStalkerAuthFailureBody(response: unknown): boolean {
|
||||
if (typeof response !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = response.trim();
|
||||
if (body.length === 0 || body.length > 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return STALKER_AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (`{ js: { error: "Authorization failed" } }` /
|
||||
* `{ js: { msg: … } }` — the same forms
|
||||
* `StalkerSessionService.isAuthorizationError()` recognizes). 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.
|
||||
*/
|
||||
export function isStalkerAuthFailureResponse(response: unknown): boolean {
|
||||
if (isStalkerAuthFailureBody(response)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
response === null ||
|
||||
typeof response !== 'object' ||
|
||||
!('js' in (response as Record<string, unknown>))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const js = (response as { js?: unknown }).js;
|
||||
if (js === null || typeof js !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { error, msg } = js as { error?: unknown; msg?: unknown };
|
||||
return [error, msg].some(
|
||||
(value) =>
|
||||
typeof value === 'string' && isStalkerJsonAuthFailurePhrase(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth-failure phrases accepted inside the STRUCTURED `js.error`/`js.msg`
|
||||
* fields. Deliberately wider than the plain-text body patterns (which stay
|
||||
* narrow to avoid matching arbitrary HTML pages): these are the same forms
|
||||
* `StalkerSessionService.isAuthorizationError()` recognizes — panels answer
|
||||
* "Invalid token", "Auth failed" or bare "unauthorized" here.
|
||||
*/
|
||||
const STALKER_JSON_AUTH_FAILURE_PATTERNS = [
|
||||
...STALKER_AUTH_FAILURE_PATTERNS,
|
||||
/auth\s+failed/i,
|
||||
/invalid\s+token/i,
|
||||
/\bunauthorized\b/i,
|
||||
/authorization/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether an ERROR MESSAGE reports an authorization failure. Uses the wide
|
||||
* phrase set (including `Invalid token` / `Auth failed`) because the input
|
||||
* is a controlled string produced by our own auth layer — e.g.
|
||||
* `Error('Profile error: Invalid token')` — not an arbitrary portal body,
|
||||
* where the same breadth would false-positive on HTML pages.
|
||||
*/
|
||||
export function isStalkerAuthFailureMessage(message: unknown): boolean {
|
||||
return (
|
||||
typeof message === 'string' && isStalkerJsonAuthFailurePhrase(message)
|
||||
);
|
||||
}
|
||||
|
||||
function isStalkerJsonAuthFailurePhrase(value: string): boolean {
|
||||
const phrase = value.trim();
|
||||
if (phrase.length === 0 || phrase.length > 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return STALKER_JSON_AUTH_FAILURE_PATTERNS.some((pattern) =>
|
||||
pattern.test(phrase)
|
||||
);
|
||||
}
|
||||
|
||||
export type StalkerProbeClassification = 'data' | 'auth-required' | 'not-a-portal';
|
||||
|
||||
/**
|
||||
* Classifies what a token-less content request got back from a candidate
|
||||
* endpoint: real JSON data (token-free panel), the middleware's plain-text
|
||||
* auth failure (endpoint exists and enforces the token), or something that
|
||||
* is not a Stalker portal at all.
|
||||
*/
|
||||
export function classifyStalkerProbeResponse(
|
||||
response: unknown
|
||||
): StalkerProbeClassification {
|
||||
if (isStalkerAuthFailureResponse(response)) {
|
||||
return 'auth-required';
|
||||
}
|
||||
|
||||
if (response !== null && typeof response === 'object') {
|
||||
const js = (response as { js?: unknown }).js;
|
||||
// The probe asks for `itv/get_genres`, whose success shape is a
|
||||
// list (or a `{data: [...]}` envelope). A bare `js` key is NOT
|
||||
// enough: panels answer HTTP 200 with `{js: {error: "Unknown
|
||||
// action"}}` or `{js: false}`, and accepting those would end
|
||||
// discovery on a broken candidate and persist an empty catalog.
|
||||
if (Array.isArray(js)) {
|
||||
return 'data';
|
||||
}
|
||||
if (js !== null && typeof js === 'object') {
|
||||
const { data, error } = js as { data?: unknown; error?: unknown };
|
||||
if (Array.isArray(data) && error === undefined) {
|
||||
return 'data';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'not-a-portal';
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP status carried by a failed Stalker transport call, when there is one.
|
||||
* The Electron handler rejects with an Error whose message is
|
||||
* `HTTP Error <code>: <statusText>` (network-level failures map to 500) —
|
||||
* but `ipcRenderer.invoke` strips every custom property from a rejected
|
||||
* value and re-wraps the message, so in the renderer the numeric `status`
|
||||
* field usually does NOT survive and the code must be parsed back out of
|
||||
* the message text.
|
||||
*/
|
||||
export function getStalkerRequestErrorStatus(
|
||||
error: unknown
|
||||
): number | undefined {
|
||||
if (error === null || typeof error !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if ('status' in error) {
|
||||
const status = (error as { status?: unknown }).status;
|
||||
if (typeof status === 'number') {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
if ('message' in error) {
|
||||
const match = /HTTP Error (\d{3})\b/.exec(
|
||||
String((error as { message?: unknown }).message ?? '')
|
||||
);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a status-less probe failure is a TIMEOUT (renderer probe budget,
|
||||
* axios request timeout, ETIMEDOUT). A timeout can be one hanging handler
|
||||
* while sibling endpoints answer fine, so discovery continues past it;
|
||||
* connection-level failures (ECONNREFUSED, ENOTFOUND, …) still stop the
|
||||
* loop — they prove the HOST is unreachable for every candidate.
|
||||
*/
|
||||
export function isStalkerProbeTimeout(error: unknown): boolean {
|
||||
if (error === null || typeof error !== 'object' || !('message' in error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = String((error as { message?: unknown }).message ?? '');
|
||||
return /timed out|timeout of \d+\s*ms|ETIMEDOUT/i.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-discovery URL rewrite (`…/c` → `portal.php`, `…/stalker_portal/c`
|
||||
* → `…/stalker_portal/server/load.php`). Kept ONLY as the fallback for
|
||||
* imports where no candidate could be probed (host unreachable); discovery
|
||||
* results always win over this guess.
|
||||
*/
|
||||
export function legacyTransformStalkerPortalUrl(url: string): string {
|
||||
url = url.replace(/\/+$/, '');
|
||||
|
||||
if (url.endsWith('/c')) {
|
||||
if (url.includes('/stalker_portal')) {
|
||||
return url.replace(
|
||||
/\/stalker_portal\/c$/,
|
||||
'/stalker_portal/server/load.php'
|
||||
);
|
||||
}
|
||||
return url.replace(/\/c$/, '/portal.php');
|
||||
}
|
||||
|
||||
if (url.includes('/stalker_portal') && !url.includes('/server/load.php')) {
|
||||
if (url.endsWith('/stalker_portal')) {
|
||||
return url + '/server/load.php';
|
||||
}
|
||||
if (!url.endsWith('/load.php')) {
|
||||
return url.replace(
|
||||
/\/stalker_portal(\/.*)?$/,
|
||||
'/stalker_portal/server/load.php'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service';
|
||||
import { StalkerPortalRepairService } from './stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const MISCLASSIFIED = {
|
||||
_id: 'portal-1',
|
||||
title: 'Canonical Ministra',
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
describe('StalkerPortalRepairService', () => {
|
||||
let service: StalkerPortalRepairService;
|
||||
let discover: jest.Mock;
|
||||
let transformPlaylistMeta: jest.Mock;
|
||||
/** What the persisted row looks like when the repair re-verifies it. */
|
||||
let persistedRow: Playlist | undefined;
|
||||
/** The row the atomic transform actually wrote, if any. */
|
||||
let writtenRow: Playlist | null;
|
||||
/** When set, the atomic write fails AFTER the transform verified. */
|
||||
let persistError: Error | null;
|
||||
let setCachedToken: jest.Mock;
|
||||
let clearCachedToken: jest.Mock;
|
||||
let refreshActiveWatchdogPlaylist: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
discover = jest.fn();
|
||||
persistedRow = MISCLASSIFIED as Playlist;
|
||||
writtenRow = null;
|
||||
persistError = null;
|
||||
// Mirrors PlaylistsService.transformPlaylistMeta semantics: the
|
||||
// transform runs on the current row inside the write queue; null
|
||||
// aborts, otherwise the returned row is persisted.
|
||||
transformPlaylistMeta = jest.fn((_id, transform) => {
|
||||
if (!persistedRow) {
|
||||
return of(null);
|
||||
}
|
||||
const next = transform(persistedRow) as Playlist | null;
|
||||
if (next === null) {
|
||||
return of(null);
|
||||
}
|
||||
if (persistError) {
|
||||
return throwError(() => persistError);
|
||||
}
|
||||
writtenRow = next;
|
||||
return of(next);
|
||||
});
|
||||
setCachedToken = jest.fn();
|
||||
clearCachedToken = jest.fn();
|
||||
refreshActiveWatchdogPlaylist = jest.fn();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: StalkerPortalDiscoveryService,
|
||||
useValue: { discover },
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
transformPlaylistMeta,
|
||||
getPlaylistById: jest.fn(() => of(persistedRow)),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StalkerSessionService,
|
||||
useValue: {
|
||||
setCachedToken,
|
||||
clearCachedToken,
|
||||
refreshActiveWatchdogPlaylist,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(StalkerPortalRepairService);
|
||||
});
|
||||
|
||||
describe('shouldAttemptRepair', () => {
|
||||
it('triggers on the middleware plain-text auth bodies', () => {
|
||||
expect(
|
||||
service.shouldAttemptRepair(MISCLASSIFIED, 'Authorization failed.')
|
||||
).toBe(true);
|
||||
expect(
|
||||
service.shouldAttemptRepair(
|
||||
MISCLASSIFIED,
|
||||
'Unauthorized request.'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('triggers on HTTP 404 — the persisted endpoint does not exist', () => {
|
||||
expect(
|
||||
service.shouldAttemptRepair(MISCLASSIFIED, {
|
||||
message: 'HTTP Error 404: Not Found',
|
||||
status: 404,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('triggers on HTTP 401/403 — discovery classifies those endpoints as auth-required', () => {
|
||||
expect(
|
||||
service.shouldAttemptRepair(MISCLASSIFIED, {
|
||||
message: 'HTTP Error 401: Unauthorized',
|
||||
status: 401,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
service.shouldAttemptRepair(MISCLASSIFIED, {
|
||||
message: 'HTTP Error 403: Forbidden',
|
||||
status: 403,
|
||||
})
|
||||
).toBe(true);
|
||||
// Endpoint-specific server errors are still not repair triggers.
|
||||
expect(
|
||||
service.shouldAttemptRepair(MISCLASSIFIED, {
|
||||
message: 'HTTP Error 500: Internal Server Error',
|
||||
status: 500,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('triggers on terminal session auth errors', () => {
|
||||
expect(
|
||||
service.shouldAttemptRepair(
|
||||
MISCLASSIFIED,
|
||||
new Error('Authorization failed after retry')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
service.shouldAttemptRepair(
|
||||
MISCLASSIFIED,
|
||||
new Error('Handshake failed: No token received')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'Profile error: Access denied.',
|
||||
'Profile error: Unauthorized request.',
|
||||
'Profile error: Invalid token',
|
||||
'Profile error: Auth failed',
|
||||
])('triggers on the wrapped profile denial %j', (message) => {
|
||||
// Authentication wraps structured denials; the trigger uses the
|
||||
// same failure set as discovery and the session service, so
|
||||
// these cannot bypass the repair.
|
||||
expect(
|
||||
service.shouldAttemptRepair(MISCLASSIFIED, new Error(message))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('never triggers on timeouts or other network failures', () => {
|
||||
expect(
|
||||
service.shouldAttemptRepair(MISCLASSIFIED, {
|
||||
type: 'ERROR',
|
||||
message: 'timeout of 15000ms exceeded',
|
||||
status: 500,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
service.shouldAttemptRepair(MISCLASSIFIED, { js: [] })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never triggers for playlists without portal coordinates', () => {
|
||||
expect(
|
||||
service.shouldAttemptRepair(
|
||||
{ _id: 'x', macAddress: 'mac' } as PlaylistMeta,
|
||||
'Authorization failed.'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairPortal', () => {
|
||||
it('persists a proven different mode and returns the patched playlist', async () => {
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'TOKEN1',
|
||||
});
|
||||
|
||||
const repaired = await service.repairPortal(MISCLASSIFIED);
|
||||
|
||||
expect(repaired).toMatchObject({
|
||||
_id: 'portal-1',
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
// The atomic transform patched the FRESH row (verified inside
|
||||
// the write queue), so user state can never be clobbered.
|
||||
expect(writtenRow).toMatchObject({
|
||||
_id: 'portal-1',
|
||||
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(
|
||||
'portal-1',
|
||||
'TOKEN1',
|
||||
expect.objectContaining({ _id: 'portal-1' })
|
||||
);
|
||||
// A repaired ACTIVE playlist must re-sync the watchdog now: a
|
||||
// simple→full flip has to start the keepalive mid-session.
|
||||
expect(refreshActiveWatchdogPlaylist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
_id: 'portal-1',
|
||||
isFullStalkerPortal: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('repairs a dead portal.php endpoint to the canonical one', async () => {
|
||||
const wrongEndpoint = {
|
||||
...MISCLASSIFIED,
|
||||
portalUrl: 'http://ministra.example/portal.php',
|
||||
} as PlaylistMeta;
|
||||
persistedRow = wrongEndpoint as Playlist;
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://ministra.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'TOKEN2',
|
||||
});
|
||||
|
||||
const repaired = await service.repairPortal(wrongEndpoint);
|
||||
|
||||
expect(repaired?.portalUrl).toBe(
|
||||
'http://ministra.example/server/load.php'
|
||||
);
|
||||
expect(service.applyOverride(wrongEndpoint).portalUrl).toBe(
|
||||
'http://ministra.example/server/load.php'
|
||||
);
|
||||
});
|
||||
|
||||
it('changes nothing when probing confirms the stored configuration', async () => {
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
|
||||
const repaired = await service.repairPortal(MISCLASSIFIED);
|
||||
|
||||
expect(repaired).toBeNull();
|
||||
expect(writtenRow).toBeNull();
|
||||
expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED);
|
||||
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changes nothing when the probe finds no working configuration', async () => {
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(writtenRow).toBeNull();
|
||||
});
|
||||
|
||||
it('changes nothing when the probe is rejected by the portal', async () => {
|
||||
discover.mockResolvedValue({
|
||||
status: 'auth-rejected',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
});
|
||||
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(writtenRow).toBeNull();
|
||||
});
|
||||
|
||||
it('probes at most once per playlist per session', async () => {
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
|
||||
await service.repairPortal(MISCLASSIFIED);
|
||||
await service.repairPortal(MISCLASSIFIED);
|
||||
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-enters for an edited configuration after awaiting a pending probe', async () => {
|
||||
// A's probe is in flight when a request from the EDITED config B
|
||||
// fails: after A settles (and is discarded by the row guard), B
|
||||
// must get its own probe instead of inheriting A's outcome.
|
||||
const edited = {
|
||||
...MISCLASSIFIED,
|
||||
portalUrl: 'http://edited.example/portal.php',
|
||||
} as PlaylistMeta;
|
||||
persistedRow = edited as Playlist;
|
||||
|
||||
let resolveFirstDiscovery!: (value: unknown) => void;
|
||||
discover.mockReturnValueOnce(
|
||||
new Promise((resolve) => (resolveFirstDiscovery = resolve))
|
||||
);
|
||||
discover.mockResolvedValueOnce({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://edited.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
|
||||
const oldRepair = service.repairPortal(MISCLASSIFIED);
|
||||
const editedRepair = service.repairPortal(edited);
|
||||
|
||||
resolveFirstDiscovery({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
|
||||
expect(await oldRepair).toBeNull();
|
||||
const repaired = await editedRepair;
|
||||
expect(discover).toHaveBeenCalledTimes(2);
|
||||
expect(repaired?.portalUrl).toBe(
|
||||
'http://edited.example/server/load.php'
|
||||
);
|
||||
});
|
||||
|
||||
it('shares one in-flight probe between concurrent failing requests', async () => {
|
||||
let resolveDiscovery!: (value: unknown) => void;
|
||||
discover.mockReturnValue(
|
||||
new Promise((resolve) => (resolveDiscovery = resolve))
|
||||
);
|
||||
|
||||
const first = service.repairPortal(MISCLASSIFIED);
|
||||
const second = service.repairPortal(MISCLASSIFIED);
|
||||
resolveDiscovery({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([first, second]);
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
expect(a?.isFullStalkerPortal).toBe(true);
|
||||
expect(b?.isFullStalkerPortal).toBe(true);
|
||||
});
|
||||
|
||||
it('hands out the completed override to later failing callers without re-probing', async () => {
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
|
||||
await service.repairPortal(MISCLASSIFIED);
|
||||
// A caller still holding the stale playlist object fails and asks
|
||||
// again: it gets the override without a second probe.
|
||||
const again = await service.repairPortal(MISCLASSIFIED);
|
||||
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
expect(again?.isFullStalkerPortal).toBe(true);
|
||||
|
||||
// A caller already on the repaired configuration gets null — its
|
||||
// failure has another cause, and retrying would loop.
|
||||
const alreadyApplied = service.applyOverride(MISCLASSIFIED);
|
||||
expect(await service.repairPortal(alreadyApplied)).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the override and re-arms probing when the user edits portal metadata', async () => {
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
await service.repairPortal(MISCLASSIFIED);
|
||||
expect(service.applyOverride(MISCLASSIFIED)).toMatchObject({
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
|
||||
// The user pointed the playlist somewhere else through the
|
||||
// playlist dialog: the ID-keyed override must not keep rewriting
|
||||
// requests to the old repaired endpoint.
|
||||
const edited = {
|
||||
...MISCLASSIFIED,
|
||||
portalUrl: 'http://other.example/portal.php',
|
||||
} as PlaylistMeta;
|
||||
expect(service.applyOverride(edited)).toBe(edited);
|
||||
|
||||
// …and the once-per-session latch re-arms so the EDITED
|
||||
// configuration may probe if it fails too.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://other.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
persistedRow = edited as Playlist;
|
||||
const repairedAgain = await service.repairPortal(edited);
|
||||
expect(discover).toHaveBeenCalledTimes(2);
|
||||
expect(repairedAgain?.portalUrl).toBe(
|
||||
'http://other.example/server/load.php'
|
||||
);
|
||||
});
|
||||
|
||||
it('discards an in-flight repair when the row was edited during the probe', async () => {
|
||||
// The probe can run for tens of seconds; a user who saves a new
|
||||
// portal URL meanwhile must win over the repair of the old one.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
persistedRow = {
|
||||
...MISCLASSIFIED,
|
||||
portalUrl: 'http://edited.example/portal.php',
|
||||
} as Playlist;
|
||||
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(writtenRow).toBeNull();
|
||||
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
|
||||
expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED);
|
||||
});
|
||||
|
||||
it('discards an in-flight repair when the MAC or identity changed during the probe', async () => {
|
||||
// The probe authenticated AS an identity — a token and watchdog
|
||||
// for the old MAC must not be installed onto the edited account.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
persistedRow = {
|
||||
...MISCLASSIFIED,
|
||||
macAddress: '00:1A:79:00:99:99',
|
||||
} as Playlist;
|
||||
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(writtenRow).toBeNull();
|
||||
expect(setCachedToken).not.toHaveBeenCalled();
|
||||
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never aliases distinct identities across field boundaries', async () => {
|
||||
// Delimiter-style fingerprints would treat serial "a|b" +
|
||||
// empty device as equal to serial "a" + device "b" and skip
|
||||
// the identity invalidation entirely.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
const pipedIdentity = {
|
||||
...MISCLASSIFIED,
|
||||
stalkerSerialNumber: 'a|b',
|
||||
} as PlaylistMeta;
|
||||
persistedRow = pipedIdentity as Playlist;
|
||||
await service.repairPortal(pipedIdentity);
|
||||
clearCachedToken.mockClear();
|
||||
|
||||
const shiftedIdentity = {
|
||||
...MISCLASSIFIED,
|
||||
stalkerSerialNumber: 'a',
|
||||
stalkerDeviceId1: 'b',
|
||||
} as PlaylistMeta;
|
||||
|
||||
// A DIFFERENT identity must invalidate, not inherit.
|
||||
expect(service.applyOverride(shiftedIdentity)).toBe(
|
||||
shiftedIdentity
|
||||
);
|
||||
expect(clearCachedToken).toHaveBeenCalledWith('portal-1');
|
||||
});
|
||||
|
||||
it('reinstalls the remembered repair when a restored configuration fails again', async () => {
|
||||
// Repair A, edit to B (drops the active override), restore A:
|
||||
// A's next failure must reinstall the remembered outcome
|
||||
// WITHOUT a second discovery — not stay broken until restart.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
await service.repairPortal(MISCLASSIFIED);
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
|
||||
const editedIdentity = {
|
||||
...MISCLASSIFIED,
|
||||
macAddress: '00:1A:79:00:44:44',
|
||||
} as PlaylistMeta;
|
||||
// The edit drops the active override…
|
||||
expect(service.applyOverride(editedIdentity)).toBe(editedIdentity);
|
||||
expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED);
|
||||
|
||||
// …and the restored configuration reinstalls it on failure.
|
||||
refreshActiveWatchdogPlaylist.mockClear();
|
||||
const restored = await service.repairPortal(MISCLASSIFIED);
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
expect(restored).toMatchObject({ isFullStalkerPortal: true });
|
||||
expect(service.applyOverride(MISCLASSIFIED)).toMatchObject({
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
// The reinstall re-syncs the watchdog exactly like a fresh
|
||||
// repair — the intermediate edit may have stopped the keepalive.
|
||||
expect(refreshActiveWatchdogPlaylist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isFullStalkerPortal: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resurrect a remembered override while the row holds another config', async () => {
|
||||
// Repair A→B, then the user edits the row to an unrelated C. A
|
||||
// stale A request failing afterwards must NOT reinstall B (it
|
||||
// would retry against B and repoint the watchdog away from C).
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://b.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
await service.repairPortal(MISCLASSIFIED);
|
||||
|
||||
const otherConfig = {
|
||||
...MISCLASSIFIED,
|
||||
portalUrl: 'http://c.example/portal.php',
|
||||
} as PlaylistMeta;
|
||||
// The edit drops the active override…
|
||||
expect(service.applyOverride(otherConfig)).toBe(otherConfig);
|
||||
persistedRow = otherConfig as Playlist;
|
||||
refreshActiveWatchdogPlaylist.mockClear();
|
||||
|
||||
// …and a stale A request does not bring it back.
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(service.applyOverride(MISCLASSIFIED)).toBe(MISCLASSIFIED);
|
||||
expect(refreshActiveWatchdogPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the override and the cached token when only the identity was edited', async () => {
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
await service.repairPortal(MISCLASSIFIED);
|
||||
expect(service.applyOverride(MISCLASSIFIED)).toMatchObject({
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
clearCachedToken.mockClear();
|
||||
|
||||
// Same URL and mode, different MAC: the repair token belongs to
|
||||
// the previous identity and must be retired with the override.
|
||||
const editedIdentity = {
|
||||
...MISCLASSIFIED,
|
||||
macAddress: '00:1A:79:00:88:88',
|
||||
} as PlaylistMeta;
|
||||
|
||||
expect(service.applyOverride(editedIdentity)).toBe(editedIdentity);
|
||||
expect(clearCachedToken).toHaveBeenCalledWith('portal-1');
|
||||
// The latch is re-armed for the edited identity.
|
||||
discover.mockClear();
|
||||
discover.mockResolvedValue({ status: 'unreachable' });
|
||||
persistedRow = editedIdentity as Playlist;
|
||||
await service.repairPortal(editedIdentity);
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-probes a DISCARDED configuration once the row is restored to it', async () => {
|
||||
// A's probe was discarded because the row moved to B mid-probe;
|
||||
// after the user restores the row to A, A's next failure must
|
||||
// probe again instead of staying dead for the session.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
persistedRow = {
|
||||
...MISCLASSIFIED,
|
||||
portalUrl: 'http://edited.example/portal.php',
|
||||
} as Playlist;
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Row restored to A → the discarded marker yields to a new probe.
|
||||
persistedRow = MISCLASSIFIED as Playlist;
|
||||
const repaired = await service.repairPortal(MISCLASSIFIED);
|
||||
expect(discover).toHaveBeenCalledTimes(2);
|
||||
expect(repaired).toMatchObject({ isFullStalkerPortal: true });
|
||||
});
|
||||
|
||||
it('re-arms the EDITED configuration after a mid-probe edit discarded a repair', async () => {
|
||||
const edited = {
|
||||
...MISCLASSIFIED,
|
||||
portalUrl: 'http://edited.example/portal.php',
|
||||
} as PlaylistMeta;
|
||||
|
||||
// Probe of the OLD config lands after the user edit → discarded.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
persistedRow = edited as Playlist;
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A stale snapshot of the already-probed config must NOT loop
|
||||
// the probe…
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(discover).toHaveBeenCalledTimes(1);
|
||||
|
||||
// …but the EDITED configuration failing later must be allowed
|
||||
// to repair without an application restart.
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://edited.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
const repaired = await service.repairPortal(edited);
|
||||
expect(discover).toHaveBeenCalledTimes(2);
|
||||
expect(repaired?.portalUrl).toBe(
|
||||
'http://edited.example/server/load.php'
|
||||
);
|
||||
});
|
||||
|
||||
it('discards an in-flight repair when the playlist was deleted during the probe', async () => {
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
persistedRow = undefined;
|
||||
|
||||
expect(await service.repairPortal(MISCLASSIFIED)).toBeNull();
|
||||
expect(writtenRow).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the session-only override when persisting fails', async () => {
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: MISCLASSIFIED.portalUrl,
|
||||
isFullStalkerPortal: true,
|
||||
});
|
||||
// The transform verified the row, but the WRITE failed.
|
||||
persistError = new Error('db locked');
|
||||
|
||||
const repaired = await service.repairPortal(MISCLASSIFIED);
|
||||
|
||||
expect(repaired?.isFullStalkerPortal).toBe(true);
|
||||
expect(service.applyOverride(MISCLASSIFIED).isFullStalkerPortal).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('clears a stale cached token when a portal turns out token-free', async () => {
|
||||
const wronglyFull = {
|
||||
...MISCLASSIFIED,
|
||||
portalUrl: 'http://panel.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
persistedRow = wronglyFull as Playlist;
|
||||
discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'http://panel.example/portal.php',
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
|
||||
const repaired = await service.repairPortal(wronglyFull);
|
||||
|
||||
expect(repaired?.isFullStalkerPortal).toBe(false);
|
||||
expect(clearCachedToken).toHaveBeenCalledWith('portal-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,490 @@
|
||||
import { Injectable, Injector, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
isFullStalkerPortalPlaylist,
|
||||
type PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service';
|
||||
import {
|
||||
getStalkerRequestErrorStatus,
|
||||
isStalkerAuthFailureMessage,
|
||||
isStalkerAuthFailureResponse,
|
||||
} from './stalker-portal-discovery.utils';
|
||||
import {
|
||||
getStalkerPortalIdentityFromPlaylist,
|
||||
stalkerIdentityFingerprint,
|
||||
} from './stalker-identity.utils';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
import {
|
||||
type StalkerPortalRepairApi,
|
||||
toStalkerSessionPlaylist,
|
||||
} from './stores/utils/stalker-request.utils';
|
||||
|
||||
/**
|
||||
* What a probe of one source configuration concluded this session:
|
||||
* an override to (re)install, 'no-change' (probed; the stored configuration
|
||||
* is what probing proves, or nothing answered), or 'discarded' (the row
|
||||
* moved on mid-probe, so the outcome never applied to any persisted state).
|
||||
*/
|
||||
type StalkerProbeRecord = StalkerPortalModeOverride | 'no-change' | 'discarded';
|
||||
|
||||
interface StalkerPortalModeOverride {
|
||||
/** The failing configuration this repair replaced. */
|
||||
sourcePortalUrl?: string;
|
||||
sourceIsFullStalkerPortal: boolean;
|
||||
/** MAC + Stalker identity the repair probe authenticated as. */
|
||||
identityFingerprint: string;
|
||||
/** The proven-working configuration. */
|
||||
portalUrl: string;
|
||||
isFullStalkerPortal: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Lazy repair for playlists whose persisted portal endpoint or mode is
|
||||
* wrong. The flag used to be a URL-shape guess frozen at import, so a
|
||||
* canonical `…/server/load.php` portal could sit misclassified as
|
||||
* token-free forever — every request answered `Authorization failed.` and
|
||||
* the only "fix" was deleting the playlist (losing favorites, recents and
|
||||
* positions).
|
||||
*
|
||||
* Deliberately NOT an eager one-shot migration: a large share of users are
|
||||
* on reseller `portal.php` panels that work without any auth, and nothing
|
||||
* short of probing can distinguish those from misclassified canonical
|
||||
* portals. Instead, repair is evidence-driven and conservative:
|
||||
*
|
||||
* - it runs only after a request ACTUALLY failed with a repair trigger
|
||||
* (the middleware's plain-text auth bodies, or HTTP 404 — a portal that
|
||||
* works is never probed, let alone rewritten);
|
||||
* - it probes at most once per SOURCE CONFIGURATION (endpoint, mode, MAC,
|
||||
* identity) per playlist per session — an edited configuration may probe
|
||||
* when it fails, an already-probed one stays latched;
|
||||
* - it persists only a configuration that discovery PROVED to answer, and
|
||||
* only when that configuration differs from the failing one.
|
||||
*
|
||||
* A successful repair also installs an in-session override so already-held
|
||||
* stale playlist objects (store state, route snapshots) start using the
|
||||
* corrected endpoint immediately — the persisted row makes it permanent.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StalkerPortalRepairService implements StalkerPortalRepairApi {
|
||||
private readonly discovery = inject(StalkerPortalDiscoveryService);
|
||||
// Resolved lazily: PlaylistsService pulls the whole persistence stack
|
||||
// (IndexedDB, snackbar, translations) and is only needed at the moment a
|
||||
// repair actually persists — never on the hot request path.
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly logger = createLogger('StalkerPortalRepair');
|
||||
|
||||
private readonly overrides = new Map<string, StalkerPortalModeOverride>();
|
||||
/**
|
||||
* The OUTCOME of every probe this session, per playlist, keyed by the
|
||||
* source-configuration fingerprint: the override the probe produced, or
|
||||
* null when it changed nothing. Keeping full history (not just the last
|
||||
* entry) stops alternating edits (A→B→A) from re-running discovery via
|
||||
* stale snapshots — and keeping the OUTCOME (not just an attempted
|
||||
* flag) lets a configuration the user restores reinstall its remembered
|
||||
* repair instead of staying broken until restart. A configuration never
|
||||
* probed — including one whose mid-probe edit discarded a repair —
|
||||
* probes when IT fails.
|
||||
*/
|
||||
private readonly probeHistory = new Map<
|
||||
string,
|
||||
Map<string, StalkerProbeRecord>
|
||||
>();
|
||||
private readonly pendingRepairs = new Map<
|
||||
string,
|
||||
Promise<PlaylistMeta | null>
|
||||
>();
|
||||
|
||||
constructor() {
|
||||
// The watchdog resolves its playlist from the persisted row; while a
|
||||
// repair's persistence is pending (or failed) that row still carries
|
||||
// the broken configuration, so pings must see the override too. The
|
||||
// typeof guard keeps isolated TestBeds with partial session mocks
|
||||
// working.
|
||||
if (
|
||||
typeof this.stalkerSession.registerWatchdogPlaylistDecorator ===
|
||||
'function'
|
||||
) {
|
||||
this.stalkerSession.registerWatchdogPlaylistDecorator((playlist) =>
|
||||
this.applyOverride(playlist)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the playlist with a completed repair applied, or the playlist
|
||||
* unchanged (same reference) when there is nothing to apply.
|
||||
*
|
||||
* The override is tied to the SOURCE configuration it repaired: it only
|
||||
* rewrites objects still carrying that failing configuration (stale
|
||||
* store snapshots). A playlist carrying anything else means the user
|
||||
* edited the portal metadata through the playlist dialog — the override
|
||||
* and the once-per-session probe latch are dropped so the edited
|
||||
* configuration is used verbatim and may repair again if IT fails.
|
||||
*/
|
||||
applyOverride<T extends PlaylistMeta>(playlist: T): T {
|
||||
const override = this.overrides.get(playlist._id);
|
||||
if (!override) {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
if (
|
||||
stalkerIdentityFingerprint(playlist) !== override.identityFingerprint
|
||||
) {
|
||||
// The MAC or Stalker identity was edited after the repair. The
|
||||
// override AND the token the repair authenticated for the
|
||||
// PREVIOUS identity must go — otherwise requests and watchdog
|
||||
// pings would pair the edited identity with a foreign session.
|
||||
this.dropOverride(playlist._id);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
if (
|
||||
playlist.portalUrl === override.portalUrl &&
|
||||
playlist.isFullStalkerPortal === override.isFullStalkerPortal
|
||||
) {
|
||||
// Already carrying the repaired values (e.g. a freshly read row).
|
||||
return playlist;
|
||||
}
|
||||
|
||||
if (
|
||||
playlist.portalUrl === override.sourcePortalUrl &&
|
||||
isFullStalkerPortalPlaylist(playlist) ===
|
||||
override.sourceIsFullStalkerPortal
|
||||
) {
|
||||
return {
|
||||
...playlist,
|
||||
portalUrl: override.portalUrl,
|
||||
isFullStalkerPortal: override.isFullStalkerPortal,
|
||||
};
|
||||
}
|
||||
|
||||
// The portal URL or mode was edited to something else entirely —
|
||||
// same story: the edited configuration is used verbatim and the
|
||||
// repair session state is retired.
|
||||
this.dropOverride(playlist._id);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires the session artifacts a repair installed for a playlist: the
|
||||
* ACTIVE override and the cached token (authenticated for the pre-edit
|
||||
* identity/endpoint). The probe history deliberately survives: it both
|
||||
* blocks re-probing of already-attempted configurations (A→B→A cannot
|
||||
* loop discovery through stale snapshots) and lets a restored
|
||||
* configuration reinstall its remembered repair.
|
||||
*/
|
||||
private dropOverride(playlistId: string): void {
|
||||
this.overrides.delete(playlistId);
|
||||
this.stalkerSession.clearCachedToken(playlistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failure justifies probing at all. Only the failure shapes a
|
||||
* wrong endpoint/mode actually produces qualify: the middleware's
|
||||
* plain-text/JSON auth failures (misclassified canonical portal
|
||||
* answering a token-less request), HTTP 404 (persisted endpoint does
|
||||
* not exist), HTTP 401/403 (endpoint behind an HTTP auth gate), and
|
||||
* the session service's terminal auth/handshake errors. Timeouts and
|
||||
* other network failures never trigger a probe —
|
||||
* a portal that is temporarily down must not be reclassified.
|
||||
*/
|
||||
shouldAttemptRepair(playlist: PlaylistMeta, failure: unknown): boolean {
|
||||
if (!playlist._id || !playlist.portalUrl || !playlist.macAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isStalkerAuthFailureResponse(failure)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = getStalkerRequestErrorStatus(failure);
|
||||
if (status === 404 || status === 401 || status === 403) {
|
||||
// 404: the persisted endpoint does not exist on this server.
|
||||
// 401/403: a token-free-classified playlist hit an HTTP auth
|
||||
// gate — discovery classifies these endpoints as auth-required,
|
||||
// so the repair must be allowed to reach it.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
failure !== null &&
|
||||
typeof failure === 'object' &&
|
||||
'message' in failure
|
||||
) {
|
||||
const message = String(
|
||||
(failure as { message?: unknown }).message ?? ''
|
||||
);
|
||||
// The SAME failure set the session service uses for error
|
||||
// messages — authentication wraps structured denials as
|
||||
// `Error('Profile error: Access denied.')` or
|
||||
// `Error('Profile error: Invalid token')`, and a narrower
|
||||
// pattern here would let those bypass the repair entirely.
|
||||
return (
|
||||
isStalkerAuthFailureMessage(message) ||
|
||||
/handshake failed/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes the stored portal and, when discovery proves a DIFFERENT
|
||||
* working configuration, persists it and returns the patched playlist
|
||||
* for a one-shot retry. Returns null when nothing may change: probe
|
||||
* found nothing, probe confirmed the stored configuration (the failure
|
||||
* has another cause, e.g. an expired subscription), or a repair for
|
||||
* this playlist already ran this session.
|
||||
*/
|
||||
async repairPortal(playlist: PlaylistMeta): Promise<PlaylistMeta | null> {
|
||||
const playlistId = playlist._id;
|
||||
|
||||
const pending = this.pendingRepairs.get(playlistId);
|
||||
if (pending) {
|
||||
// Wait the in-flight probe out, then RE-ENTER: the caller may
|
||||
// carry a different (edited) configuration whose fingerprint
|
||||
// was never attempted — it must get its own probe instead of
|
||||
// inheriting whatever the old repair concluded.
|
||||
await pending;
|
||||
return this.repairPortal(playlist);
|
||||
}
|
||||
|
||||
const fingerprint = this.repairSourceFingerprint(playlist);
|
||||
const history = this.probeHistory.get(playlistId) ?? new Map();
|
||||
const record = history.get(fingerprint) as
|
||||
| StalkerProbeRecord
|
||||
| undefined;
|
||||
if (record === 'discarded') {
|
||||
// The probe for this configuration was discarded because the
|
||||
// row had moved on mid-probe. If the row has since been
|
||||
// RESTORED to it, the outcome was never recorded — probe again.
|
||||
// A stale snapshot (row still elsewhere) stays declined, gated
|
||||
// by one cheap row read instead of a discovery run.
|
||||
if (!(await this.rowCurrentlyMatches(playlist))) {
|
||||
return this.reapplyIfChanged(playlist);
|
||||
}
|
||||
history.delete(fingerprint);
|
||||
} else if (record !== undefined) {
|
||||
if (
|
||||
record !== 'no-change' &&
|
||||
!this.overrides.has(playlistId) &&
|
||||
// Reinstall ONLY when the persisted row actually carries
|
||||
// this configuration again. A stale request for A while the
|
||||
// row now holds an unrelated C must not resurrect A's
|
||||
// override — that would retry against B and repoint the
|
||||
// watchdog away from C.
|
||||
(await this.rowCurrentlyMatches(playlist))
|
||||
) {
|
||||
// The user restored a configuration whose override was
|
||||
// dropped by an intermediate edit: reinstall the remembered
|
||||
// outcome — probing again is unnecessary, and doing nothing
|
||||
// would leave the restored configuration broken until
|
||||
// restart.
|
||||
this.overrides.set(playlistId, record);
|
||||
// Same synchronization as a fresh repair: if the
|
||||
// intermediate configuration stopped the active watchdog,
|
||||
// the restored full-portal session needs its keepalive back.
|
||||
this.stalkerSession.refreshActiveWatchdogPlaylist(
|
||||
toStalkerSessionPlaylist(this.applyOverride(playlist))
|
||||
);
|
||||
}
|
||||
return this.reapplyIfChanged(playlist);
|
||||
}
|
||||
|
||||
// Reserved BEFORE the probe; the run overwrites it with the
|
||||
// produced override or the 'discarded' marker.
|
||||
history.set(fingerprint, 'no-change');
|
||||
this.probeHistory.set(playlistId, history);
|
||||
const run = this.runRepair(playlist);
|
||||
this.pendingRepairs.set(playlistId, run);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
this.pendingRepairs.delete(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a probe's outcome depends on: endpoint, mode, MAC and the
|
||||
* full Stalker identity — the same field set `rowStillMatchesSource`
|
||||
* verifies before committing.
|
||||
*/
|
||||
private repairSourceFingerprint(playlist: PlaylistMeta): string {
|
||||
// JSON-encoded for the same reason as the identity fingerprint:
|
||||
// unrestricted values must not alias across field boundaries.
|
||||
return JSON.stringify([
|
||||
playlist.portalUrl ?? '',
|
||||
isFullStalkerPortalPlaylist(playlist),
|
||||
stalkerIdentityFingerprint(playlist),
|
||||
]);
|
||||
}
|
||||
|
||||
private reapplyIfChanged(playlist: PlaylistMeta): PlaylistMeta | null {
|
||||
const applied = this.applyOverride(playlist);
|
||||
return applied === playlist ? null : applied;
|
||||
}
|
||||
|
||||
private async runRepair(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<PlaylistMeta | null> {
|
||||
const outcome = await this.discovery.discover(
|
||||
playlist.portalUrl ?? '',
|
||||
playlist.macAddress ?? '',
|
||||
getStalkerPortalIdentityFromPlaylist(playlist)
|
||||
);
|
||||
|
||||
if (outcome.status !== 'resolved') {
|
||||
this.logger.info(
|
||||
`Portal probe found no working configuration (${outcome.status}); leaving playlist untouched`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedMode = isFullStalkerPortalPlaylist(playlist);
|
||||
if (
|
||||
outcome.portalUrl === playlist.portalUrl &&
|
||||
outcome.isFullStalkerPortal === storedMode
|
||||
) {
|
||||
// The stored configuration is exactly what probing proves — the
|
||||
// failure has a different cause and rewriting would fix nothing.
|
||||
return null;
|
||||
}
|
||||
|
||||
// TOCTOU guard: the probe can run for tens of seconds, and the user
|
||||
// may have edited the portal metadata (or deleted the playlist)
|
||||
// meanwhile. The verification and the patch run ATOMICALLY inside
|
||||
// the per-playlist write queue — a plain read-check-then-update
|
||||
// pair would still race an edit that is queued but not committed.
|
||||
// The transform patches the FRESH row, so nothing stale can clobber
|
||||
// user state; returning null aborts without writing.
|
||||
let verifiedAgainstRow = false;
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.injector
|
||||
.get(PlaylistsService)
|
||||
.transformPlaylistMeta(playlist._id, (row) => {
|
||||
if (!this.rowMatchesSource(row, playlist, storedMode)) {
|
||||
return null;
|
||||
}
|
||||
verifiedAgainstRow = true;
|
||||
return {
|
||||
...row,
|
||||
portalUrl: outcome.portalUrl,
|
||||
isFullStalkerPortal: outcome.isFullStalkerPortal,
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
// A failed WRITE after successful verification keeps the
|
||||
// session-only override below; a failed READ means the premise
|
||||
// could not be verified and the repair is discarded.
|
||||
this.logger.warn(
|
||||
'Persisting repaired portal mode failed',
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (!verifiedAgainstRow) {
|
||||
this.logger.info(
|
||||
'Portal configuration changed while probing; discarding repair'
|
||||
);
|
||||
// Marked explicitly: a later failure of this configuration may
|
||||
// probe again once the row is RESTORED to it — unlike a probe
|
||||
// whose outcome genuinely applied ('no-change'/override).
|
||||
this.probeHistory
|
||||
.get(playlist._id)
|
||||
?.set(this.repairSourceFingerprint(playlist), 'discarded');
|
||||
return null;
|
||||
}
|
||||
|
||||
const override: StalkerPortalModeOverride = {
|
||||
sourcePortalUrl: playlist.portalUrl,
|
||||
sourceIsFullStalkerPortal: storedMode,
|
||||
identityFingerprint: stalkerIdentityFingerprint(playlist),
|
||||
portalUrl: outcome.portalUrl,
|
||||
isFullStalkerPortal: outcome.isFullStalkerPortal,
|
||||
};
|
||||
this.overrides.set(playlist._id, override);
|
||||
this.probeHistory
|
||||
.get(playlist._id)
|
||||
?.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(
|
||||
playlist._id,
|
||||
outcome.token,
|
||||
playlist
|
||||
);
|
||||
} else if (!outcome.isFullStalkerPortal) {
|
||||
this.stalkerSession.clearCachedToken(playlist._id);
|
||||
}
|
||||
|
||||
// If this playlist currently owns the watchdog, re-sync it with the
|
||||
// repaired configuration: a simple→full repair must START the
|
||||
// keepalive and an endpoint change must repoint it — the session
|
||||
// service otherwise keeps the activation-time snapshot forever.
|
||||
this.stalkerSession.refreshActiveWatchdogPlaylist(
|
||||
toStalkerSessionPlaylist(this.applyOverride(playlist))
|
||||
);
|
||||
|
||||
this.logger.info(
|
||||
`Repaired portal mode: isFullStalkerPortal=${outcome.isFullStalkerPortal}`
|
||||
);
|
||||
|
||||
return this.applyOverride(playlist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap gate for retrying a DISCARDED configuration: reads the current
|
||||
* row and reports whether it now carries the caller's configuration.
|
||||
* Only avoids pointless discovery runs — the authoritative check stays
|
||||
* the atomic transform.
|
||||
*/
|
||||
private async rowCurrentlyMatches(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const row = await firstValueFrom(
|
||||
this.injector
|
||||
.get(PlaylistsService)
|
||||
.getPlaylistById(playlist._id)
|
||||
);
|
||||
return (
|
||||
!!row &&
|
||||
this.repairSourceFingerprint(row) ===
|
||||
this.repairSourceFingerprint(playlist)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the persisted row still carries the configuration the repair
|
||||
* was computed for — endpoint, mode, and the identity the probe
|
||||
* authenticated as. Runs synchronously INSIDE the atomic transform.
|
||||
*/
|
||||
private rowMatchesSource(
|
||||
row: PlaylistMeta,
|
||||
playlist: PlaylistMeta,
|
||||
sourceMode: boolean
|
||||
): boolean {
|
||||
return (
|
||||
row.portalUrl === playlist.portalUrl &&
|
||||
isFullStalkerPortalPlaylist(row) === sourceMode &&
|
||||
stalkerIdentityFingerprint(row) ===
|
||||
stalkerIdentityFingerprint(playlist)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { of } from 'rxjs';
|
||||
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
STALKER_SERIAL_NUMBER,
|
||||
@@ -23,6 +24,327 @@ type GetProfileWithIdentity = (
|
||||
handshakeRandom: string
|
||||
) => Promise<StalkerProfileResponse>;
|
||||
|
||||
describe('StalkerSessionService watchdog row resolution', () => {
|
||||
const activationSnapshot = {
|
||||
_id: 'portal-1',
|
||||
title: 'Portal',
|
||||
portalUrl: 'https://portal.example.com/server/load.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
isFullStalkerPortal: true,
|
||||
lastUsage: '',
|
||||
} as unknown as Playlist;
|
||||
|
||||
let sendIpcEvent: jest.Mock;
|
||||
let getPlaylistById: jest.Mock;
|
||||
let service: StalkerSessionService;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: {
|
||||
subtle: {
|
||||
digest: jest.fn(
|
||||
async () => new Uint8Array(20).fill(1).buffer
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
sendIpcEvent = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ js: { token: 'TOK', random: 'r' } });
|
||||
getPlaylistById = jest.fn();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerSessionService,
|
||||
{ provide: DataService, useValue: { sendIpcEvent } },
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: { getPlaylistById },
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(StalkerSessionService);
|
||||
});
|
||||
|
||||
it('authenticates watchdog pings as the freshly persisted row, not the activation snapshot', async () => {
|
||||
// The user edited the MAC after the watchdog started: the very next
|
||||
// ping must use the stored row — pairing the old identity would
|
||||
// keep an old session alive and repopulate the token cache with it.
|
||||
const editedRow = {
|
||||
...activationSnapshot,
|
||||
macAddress: '00:1A:79:00:77:77',
|
||||
};
|
||||
getPlaylistById.mockReturnValue(of(editedRow));
|
||||
|
||||
service.setActiveWatchdogPlaylist(activationSnapshot);
|
||||
// The init ping runs on a floating promise chain.
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(getPlaylistById).toHaveBeenCalledWith('portal-1');
|
||||
expect(sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
macAddress: '00:1A:79:00:77:77',
|
||||
})
|
||||
);
|
||||
service.setActiveWatchdogPlaylist(null);
|
||||
});
|
||||
|
||||
it('overlays the registered repair decorator on the resolved row', async () => {
|
||||
// Simple→full repair whose persistence has not landed yet: the row
|
||||
// still says simple, and without the overlay the ping would stop
|
||||
// the freshly started keepalive.
|
||||
const simpleRow = {
|
||||
...activationSnapshot,
|
||||
isFullStalkerPortal: false,
|
||||
};
|
||||
getPlaylistById.mockReturnValue(of(simpleRow));
|
||||
service.registerWatchdogPlaylistDecorator((playlist) => ({
|
||||
...playlist,
|
||||
isFullStalkerPortal: true,
|
||||
}));
|
||||
|
||||
service.setActiveWatchdogPlaylist(activationSnapshot);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// The keepalive survived (no stopWatchdog) and authenticated.
|
||||
expect(sendIpcEvent).toHaveBeenCalled();
|
||||
service.setActiveWatchdogPlaylist(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StalkerSessionService identity-tagged token cache', () => {
|
||||
const playlistA = {
|
||||
_id: 'portal-1',
|
||||
title: 'Portal',
|
||||
portalUrl: 'https://portal.example.com/server/load.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
isFullStalkerPortal: true,
|
||||
lastUsage: '',
|
||||
} as unknown as Playlist;
|
||||
|
||||
let sendIpcEvent: jest.Mock;
|
||||
let service: StalkerSessionService;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: {
|
||||
subtle: {
|
||||
digest: jest.fn(
|
||||
async () => new Uint8Array(20).fill(1).buffer
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
sendIpcEvent = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ js: { token: 'FRESH', random: 'r' } });
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerSessionService,
|
||||
{ provide: DataService, useValue: { sendIpcEvent } },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(StalkerSessionService);
|
||||
});
|
||||
|
||||
it('reuses a cached token only for the identity it was negotiated for', async () => {
|
||||
service.setCachedToken('portal-1', 'OLD-IDENTITY-TOKEN', playlistA);
|
||||
|
||||
const sameIdentity = await service.ensureToken(playlistA);
|
||||
expect(sameIdentity.token).toBe('OLD-IDENTITY-TOKEN');
|
||||
expect(sendIpcEvent).not.toHaveBeenCalled();
|
||||
|
||||
// The user edited the MAC: the cached session belongs to the old
|
||||
// identity and must be replaced by a fresh authentication.
|
||||
const editedIdentity = {
|
||||
...playlistA,
|
||||
macAddress: '00:1A:79:00:66:66',
|
||||
} as Playlist;
|
||||
const reAuthenticated = await service.ensureToken(editedIdentity);
|
||||
|
||||
expect(reAuthenticated.token).toBe('FRESH');
|
||||
expect(sendIpcEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not hand an in-flight authentication result to an edited identity', async () => {
|
||||
// Deferred transport: the first auth (old identity) is still in
|
||||
// flight when the edited identity asks for a token.
|
||||
const pendingResolvers: Array<(value: unknown) => void> = [];
|
||||
sendIpcEvent.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
pendingResolvers.push(resolve);
|
||||
})
|
||||
);
|
||||
|
||||
const oldAuth = service.ensureToken(playlistA);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
const editedIdentity = {
|
||||
...playlistA,
|
||||
macAddress: '00:1A:79:00:55:55',
|
||||
} as Playlist;
|
||||
const editedAuth = service.ensureToken(editedIdentity);
|
||||
|
||||
// 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();
|
||||
}
|
||||
pendingResolvers[1]?.({ js: {} });
|
||||
await expect(oldAuth).resolves.toMatchObject({ token: 'TOKEN-OLD' });
|
||||
|
||||
// The edited identity re-enters and negotiates its OWN session.
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
pendingResolvers[2]?.({ js: { token: 'TOKEN-NEW', random: 'r' } });
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
pendingResolvers[3]?.({ js: {} });
|
||||
|
||||
await expect(editedAuth).resolves.toMatchObject({
|
||||
token: 'TOKEN-NEW',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats IPC-wrapped HTTP 401/403 as an authorization failure', async () => {
|
||||
// The custom `status` property does not survive ipcRenderer, so an
|
||||
// expired-token 403 arrives as message text only.
|
||||
service.setCachedToken('portal-1', 'EXPIRED', playlistA);
|
||||
sendIpcEvent.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Error invoking remote method 'STALKER_REQUEST': HTTP Error 403: Forbidden"
|
||||
)
|
||||
);
|
||||
sendIpcEvent.mockResolvedValue({ js: { token: 'FRESH', random: 'r' } });
|
||||
|
||||
await service
|
||||
.makeAuthenticatedRequest(playlistA, { action: 'get_genres' })
|
||||
.catch(() => undefined);
|
||||
|
||||
// The dead token was retired rather than kept for the next caller.
|
||||
expect(service.getCachedToken('portal-1')).not.toBe('EXPIRED');
|
||||
});
|
||||
|
||||
it.each(['Access denied.', 'Unauthorized request.'])(
|
||||
'retires the token for the %j plain-text failure too',
|
||||
async (body) => {
|
||||
// The session predicate shares the discovery/repair failure
|
||||
// set: a phrase one layer treats as an auth failure must not be
|
||||
// ignored here, or the expired token survives the session.
|
||||
service.setCachedToken('portal-1', 'EXPIRED', playlistA);
|
||||
sendIpcEvent.mockResolvedValue(body);
|
||||
|
||||
await service
|
||||
.makeAuthenticatedRequest(
|
||||
playlistA,
|
||||
{ action: 'get_genres' },
|
||||
false
|
||||
)
|
||||
.catch(() => undefined);
|
||||
|
||||
expect(service.getCachedToken('portal-1')).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('retires a failed token even on the no-retry path (watchdog pings)', async () => {
|
||||
service.setCachedToken('portal-1', 'DEAD', playlistA);
|
||||
sendIpcEvent.mockResolvedValue('Authorization failed.');
|
||||
|
||||
await expect(
|
||||
service.makeAuthenticatedRequest(
|
||||
playlistA,
|
||||
{ action: 'get_events' },
|
||||
false
|
||||
)
|
||||
).rejects.toThrow('Authorization failed after retry');
|
||||
|
||||
// Leaving the dead token cached would hand it to the next caller.
|
||||
expect(service.getCachedToken('portal-1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StalkerSessionService.refreshActiveWatchdogPlaylist', () => {
|
||||
const basePlaylist = {
|
||||
_id: 'portal-1',
|
||||
title: 'Portal',
|
||||
portalUrl: 'https://portal.example.com/server/load.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
isFullStalkerPortal: false,
|
||||
lastUsage: '',
|
||||
} as unknown as Playlist;
|
||||
|
||||
let service: StalkerSessionService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerSessionService,
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: {
|
||||
sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(StalkerSessionService);
|
||||
});
|
||||
|
||||
it('re-applies the repaired configuration to the ACTIVE watchdog playlist', () => {
|
||||
service.setActiveWatchdogPlaylist(basePlaylist);
|
||||
const apply = jest.spyOn(service, 'setActiveWatchdogPlaylist');
|
||||
|
||||
const repaired = {
|
||||
...basePlaylist,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
service.refreshActiveWatchdogPlaylist(repaired);
|
||||
|
||||
// Delegation is the contract: setActiveWatchdogPlaylist owns the
|
||||
// start/stop/repoint logic, refresh only feeds it the fresh row.
|
||||
expect(apply).toHaveBeenCalledWith(repaired);
|
||||
service.setActiveWatchdogPlaylist(null);
|
||||
});
|
||||
|
||||
it('ignores playlists that do not own the watchdog', () => {
|
||||
service.setActiveWatchdogPlaylist(basePlaylist);
|
||||
const apply = jest.spyOn(service, 'setActiveWatchdogPlaylist');
|
||||
|
||||
service.refreshActiveWatchdogPlaylist({
|
||||
...basePlaylist,
|
||||
_id: 'other-portal',
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(apply).not.toHaveBeenCalled();
|
||||
service.setActiveWatchdogPlaylist(null);
|
||||
});
|
||||
|
||||
it('is a no-op when no watchdog playlist is active at all', () => {
|
||||
const apply = jest.spyOn(service, 'setActiveWatchdogPlaylist');
|
||||
|
||||
service.refreshActiveWatchdogPlaylist({
|
||||
...basePlaylist,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist);
|
||||
|
||||
expect(apply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StalkerSessionService identity payloads', () => {
|
||||
const portalUrl =
|
||||
'https://portal.example.com/stalker_portal/server/load.php';
|
||||
@@ -261,7 +583,7 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
service.setCachedToken(playlist._id, 'stale-token');
|
||||
service.setCachedToken(playlist._id, 'stale-token', playlist);
|
||||
|
||||
let release: (value: { token: string }) => void = () => undefined;
|
||||
jest.spyOn(service, 'authenticate').mockImplementation(
|
||||
@@ -296,7 +618,7 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
jest.spyOn(service, 'ensureToken')
|
||||
.mockResolvedValueOnce({ token: 'stale-token' })
|
||||
.mockResolvedValueOnce({ token: 'fresh-token' });
|
||||
service.setCachedToken(playlist._id, 'fresh-token');
|
||||
service.setCachedToken(playlist._id, 'fresh-token', playlist);
|
||||
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: 'Authorization failed. 75' })
|
||||
@@ -327,7 +649,7 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
jest.spyOn(service, 'ensureToken')
|
||||
.mockResolvedValueOnce({ token: 'dead-token' })
|
||||
.mockResolvedValueOnce({ token: 'new-token' });
|
||||
service.setCachedToken(playlist._id, 'dead-token');
|
||||
service.setCachedToken(playlist._id, 'dead-token', playlist);
|
||||
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: 'Authorization failed. 75' })
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Playlist, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { Injectable, Injector, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
isFullStalkerPortalPlaylist,
|
||||
isFullStalkerPortalUrl,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
STALKER_REQUEST,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
isStalkerAuthFailureMessage,
|
||||
isStalkerAuthFailureResponse,
|
||||
} from './stalker-portal-discovery.utils';
|
||||
import {
|
||||
getStalkerPortalIdentityFromPlaylist,
|
||||
LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
normalizeStalkerPortalIdentity,
|
||||
stalkerIdentityFingerprint,
|
||||
type StalkerPortalIdentity,
|
||||
} from './stalker-identity.utils';
|
||||
|
||||
@@ -88,16 +100,33 @@ interface StalkerAuthConfirmationResponse {
|
||||
})
|
||||
export class StalkerSessionService {
|
||||
private dataService = inject(DataService);
|
||||
// Lazy: PlaylistsService drags the persistence stack and is only needed
|
||||
// when a watchdog ping re-resolves its playlist from the stored row.
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly logger = createLogger('StalkerSession');
|
||||
|
||||
// In-memory token cache for current session (keyed by playlist ID)
|
||||
private tokenCache = new Map<string, string>();
|
||||
// In-memory token cache for the current session, keyed by playlist ID
|
||||
// and tagged with the identity fingerprint the session was negotiated
|
||||
// for — an edited MAC/identity must never inherit the previous token.
|
||||
private tokenCache = new Map<
|
||||
string,
|
||||
{ token: string; identityFingerprint: string }
|
||||
>();
|
||||
private watchdogPlaylistDecorator:
|
||||
| ((playlist: Playlist) => Playlist)
|
||||
| null = null;
|
||||
|
||||
// Pending authentication promises to prevent race conditions
|
||||
// When multiple requests need a token simultaneously, they all wait for the same auth
|
||||
// Pending authentication promises to prevent race conditions.
|
||||
// When multiple requests need a token simultaneously, they all wait for
|
||||
// the same auth — but ONLY when they act as the same identity: a result
|
||||
// negotiated for a pre-edit identity must not be adopted by requests
|
||||
// carrying the edited one.
|
||||
private pendingAuth = new Map<
|
||||
string,
|
||||
Promise<{ token: string; serialNumber?: string }>
|
||||
{
|
||||
promise: Promise<{ token: string; serialNumber?: string }>;
|
||||
identityFingerprint: string;
|
||||
}
|
||||
>();
|
||||
private watchdogIntervals = new Map<
|
||||
string,
|
||||
@@ -108,27 +137,49 @@ export class StalkerSessionService {
|
||||
private activeWatchdogPlaylistId: string | null = null;
|
||||
|
||||
/**
|
||||
* Checks if a URL is a full stalker portal URL (requires handshake)
|
||||
* Full stalker portal URLs contain /stalker_portal/ in the path
|
||||
* Checks if a URL looks like a full stalker portal URL (requires
|
||||
* handshake). Delegates to the shared predicate in
|
||||
* `@iptvnator/shared/interfaces` — the flag persisted by endpoint
|
||||
* discovery is authoritative; this URL rule is only the legacy fallback.
|
||||
*/
|
||||
isFullStalkerPortal(url: string): boolean {
|
||||
return (
|
||||
url.includes('/stalker_portal/') || url.includes('/server/load.php')
|
||||
);
|
||||
return isFullStalkerPortalUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cached token for a playlist, or null if not cached
|
||||
* Gets the cached token for a playlist, or null if not cached.
|
||||
* Identity validation happens in `ensureToken`; this raw accessor stays
|
||||
* for playback fast paths that cannot supply an identity (PR 6 scope).
|
||||
*/
|
||||
getCachedToken(playlistId: string): string | null {
|
||||
return this.tokenCache.get(playlistId) || null;
|
||||
return this.tokenCache.get(playlistId)?.token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a token in the cache
|
||||
* Caches a token together with the identity fingerprint of the playlist
|
||||
* the session was negotiated for.
|
||||
*/
|
||||
setCachedToken(playlistId: string, token: string): void {
|
||||
this.tokenCache.set(playlistId, token);
|
||||
setCachedToken(
|
||||
playlistId: string,
|
||||
token: string,
|
||||
identitySource: PlaylistMeta
|
||||
): void {
|
||||
this.tokenCache.set(playlistId, {
|
||||
token,
|
||||
identityFingerprint: stalkerIdentityFingerprint(identitySource),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the repair layer overlay its in-session override on the row a
|
||||
* watchdog ping resolves: the persisted row can still carry a
|
||||
* pre-repair configuration while persistence is pending (or failed),
|
||||
* and pinging that configuration would stop or misdirect the keepalive.
|
||||
*/
|
||||
registerWatchdogPlaylistDecorator(
|
||||
decorator: (playlist: Playlist) => Playlist
|
||||
): void {
|
||||
this.watchdogPlaylistDecorator = decorator;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,7 +207,7 @@ export class StalkerSessionService {
|
||||
|
||||
if (
|
||||
!playlist ||
|
||||
!playlist.isFullStalkerPortal ||
|
||||
!isFullStalkerPortalPlaylist(playlist) ||
|
||||
!playlist.portalUrl ||
|
||||
!playlist.macAddress
|
||||
) {
|
||||
@@ -169,6 +220,22 @@ export class StalkerSessionService {
|
||||
this.startWatchdog(playlist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-evaluates the watchdog for a playlist whose portal configuration
|
||||
* was just repaired. Only reacts when the playlist IS the active
|
||||
* watchdog target: a simple→full repair starts the required keepalive,
|
||||
* full→simple stops it, and an endpoint change repoints the pings —
|
||||
* without waiting for the next route activation (the activation-time
|
||||
* snapshot in `watchdogPlaylists` would otherwise stay stale).
|
||||
*/
|
||||
refreshActiveWatchdogPlaylist(playlist: Playlist): void {
|
||||
if (this.activeWatchdogPlaylistId !== playlist._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setActiveWatchdogPlaylist(playlist);
|
||||
}
|
||||
|
||||
private startWatchdog(playlist: Playlist): void {
|
||||
const playlistId = playlist._id;
|
||||
this.watchdogPlaylists.set(playlistId, playlist);
|
||||
@@ -196,6 +263,47 @@ export class StalkerSessionService {
|
||||
this.watchdogInFlight.delete(playlistId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The playlist a watchdog 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
|
||||
* ping cycle — the activation-time snapshot is only the fallback when
|
||||
* the row cannot be read.
|
||||
*/
|
||||
private async resolveWatchdogPlaylist(
|
||||
playlistId: string
|
||||
): Promise<Playlist | undefined> {
|
||||
try {
|
||||
const row = await firstValueFrom(
|
||||
this.injector
|
||||
.get(PlaylistsService)
|
||||
.getPlaylistById(playlistId)
|
||||
);
|
||||
if (row) {
|
||||
const playlist = row as Playlist;
|
||||
// Keep the fallback snapshot fresh for the next cycle.
|
||||
this.watchdogPlaylists.set(playlistId, playlist);
|
||||
return this.decorateWatchdogPlaylist(playlist);
|
||||
}
|
||||
} catch {
|
||||
// Store unavailable (e.g. isolated tests): keep the snapshot.
|
||||
}
|
||||
|
||||
const snapshot = this.watchdogPlaylists.get(playlistId);
|
||||
return snapshot ? this.decorateWatchdogPlaylist(snapshot) : snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlays the repair layer's in-session override (when registered) so
|
||||
* a ping never authenticates against a configuration a completed repair
|
||||
* has already proven broken — even before persistence lands.
|
||||
*/
|
||||
private decorateWatchdogPlaylist(playlist: Playlist): Playlist {
|
||||
return this.watchdogPlaylistDecorator
|
||||
? this.watchdogPlaylistDecorator(playlist)
|
||||
: playlist;
|
||||
}
|
||||
|
||||
private async sendWatchdogPing(
|
||||
playlistId: string,
|
||||
init: '0' | '1'
|
||||
@@ -204,19 +312,21 @@ export class StalkerSessionService {
|
||||
return;
|
||||
}
|
||||
|
||||
const playlist = this.watchdogPlaylists.get(playlistId);
|
||||
if (
|
||||
!playlist ||
|
||||
!playlist.portalUrl ||
|
||||
!playlist.macAddress ||
|
||||
!playlist.isFullStalkerPortal
|
||||
) {
|
||||
this.stopWatchdog(playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Claimed BEFORE the row read: it awaits, and two overlapping pings
|
||||
// passing the check together would double-fire the keepalive.
|
||||
this.watchdogInFlight.add(playlistId);
|
||||
try {
|
||||
const playlist = await this.resolveWatchdogPlaylist(playlistId);
|
||||
if (
|
||||
!playlist ||
|
||||
!playlist.portalUrl ||
|
||||
!playlist.macAddress ||
|
||||
!isFullStalkerPortalPlaylist(playlist)
|
||||
) {
|
||||
this.stopWatchdog(playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.makeAuthenticatedRequest(
|
||||
playlist,
|
||||
{
|
||||
@@ -417,6 +527,8 @@ export class StalkerSessionService {
|
||||
): Promise<{
|
||||
token: string;
|
||||
accountInfo?: StalkerProfileResponse['js']['account_info'];
|
||||
/** Raw envelope so callers can apply their own failure checks. */
|
||||
profileResponse?: StalkerProfileResponse;
|
||||
}> {
|
||||
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
|
||||
|
||||
@@ -451,6 +563,7 @@ export class StalkerSessionService {
|
||||
return {
|
||||
token,
|
||||
accountInfo: profileResponse?.js?.account_info,
|
||||
profileResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
// Profile fetch failed - this is a real error, propagate it
|
||||
@@ -468,24 +581,52 @@ export class StalkerSessionService {
|
||||
playlist: Playlist
|
||||
): Promise<{ token: string | null; serialNumber?: string }> {
|
||||
// If not a full stalker portal, no token needed
|
||||
if (!playlist.isFullStalkerPortal) {
|
||||
if (!isFullStalkerPortalPlaylist(playlist)) {
|
||||
return { token: null };
|
||||
}
|
||||
|
||||
const identity = getStalkerPortalIdentityFromPlaylist(playlist);
|
||||
|
||||
// Check in-memory cache first (valid for current session only)
|
||||
const cachedToken = this.getCachedToken(playlist._id);
|
||||
if (cachedToken) {
|
||||
return { token: cachedToken, serialNumber: identity.serialNumber };
|
||||
// Check in-memory cache first (valid for current session only).
|
||||
const cached = this.tokenCache.get(playlist._id);
|
||||
if (cached) {
|
||||
if (
|
||||
cached.identityFingerprint ===
|
||||
stalkerIdentityFingerprint(playlist)
|
||||
) {
|
||||
return {
|
||||
token: cached.token,
|
||||
serialNumber: identity.serialNumber,
|
||||
};
|
||||
}
|
||||
// The cached session was negotiated for a DIFFERENT identity
|
||||
// (the playlist was edited): retire it and authenticate as the
|
||||
// current one instead of pairing new identity with old session.
|
||||
this.clearCachedToken(playlist._id);
|
||||
}
|
||||
|
||||
// Check if there's already a pending authentication for this playlist
|
||||
// This prevents race conditions when multiple resources request a token simultaneously
|
||||
const pendingPromise = this.pendingAuth.get(playlist._id);
|
||||
if (pendingPromise) {
|
||||
this.logger.debug('Waiting for pending authentication...');
|
||||
return pendingPromise;
|
||||
const pendingEntry = this.pendingAuth.get(playlist._id);
|
||||
if (pendingEntry) {
|
||||
if (
|
||||
pendingEntry.identityFingerprint ===
|
||||
stalkerIdentityFingerprint(playlist)
|
||||
) {
|
||||
this.logger.debug('Waiting for pending authentication...');
|
||||
return pendingEntry.promise;
|
||||
}
|
||||
|
||||
// An authentication for a DIFFERENT (pre-edit) identity is in
|
||||
// flight. Its result must not be adopted, but starting a
|
||||
// competing handshake would strand it with a dead token on
|
||||
// strict portals — wait for it to settle, then re-enter and
|
||||
// authenticate as the current identity.
|
||||
this.logger.debug(
|
||||
'Waiting out an authentication for a different identity...'
|
||||
);
|
||||
await pendingEntry.promise.catch(() => undefined);
|
||||
return this.ensureToken(playlist);
|
||||
}
|
||||
|
||||
// No cached token - need to do full authentication (handshake + get_profile)
|
||||
@@ -506,7 +647,7 @@ export class StalkerSessionService {
|
||||
macAddress,
|
||||
identity
|
||||
);
|
||||
this.setCachedToken(playlist._id, token);
|
||||
this.setCachedToken(playlist._id, token, playlist);
|
||||
return { token, serialNumber: identity.serialNumber };
|
||||
} finally {
|
||||
// Clean up pending promise regardless of success/failure
|
||||
@@ -515,7 +656,10 @@ export class StalkerSessionService {
|
||||
})();
|
||||
|
||||
// Store the pending promise so other concurrent requests can wait on it
|
||||
this.pendingAuth.set(playlist._id, authPromise);
|
||||
this.pendingAuth.set(playlist._id, {
|
||||
promise: authPromise,
|
||||
identityFingerprint: stalkerIdentityFingerprint(playlist),
|
||||
});
|
||||
|
||||
return authPromise;
|
||||
}
|
||||
@@ -553,7 +697,7 @@ export class StalkerSessionService {
|
||||
this.logger.debug('Waiting for pending authentication...');
|
||||
// A failed pending auth must not abort the refresh; this call
|
||||
// performs its own handshake either way.
|
||||
await inFlight.catch(() => undefined);
|
||||
await inFlight.promise.catch(() => undefined);
|
||||
}
|
||||
|
||||
// Publish the slot before the first await so no other waiter can
|
||||
@@ -574,7 +718,11 @@ export class StalkerSessionService {
|
||||
// Waiters attach their own handlers; this one only keeps a
|
||||
// rejected slot from surfacing as an unhandled rejection.
|
||||
void slot.catch(() => undefined);
|
||||
this.pendingAuth.set(playlist._id, slot);
|
||||
const slotEntry = {
|
||||
promise: slot,
|
||||
identityFingerprint: stalkerIdentityFingerprint(playlist),
|
||||
};
|
||||
this.pendingAuth.set(playlist._id, slotEntry);
|
||||
|
||||
// ensureToken() reads tokenCache before pendingAuth, so leaving the
|
||||
// old token there would hand a token this handshake is about to
|
||||
@@ -588,7 +736,7 @@ export class StalkerSessionService {
|
||||
macAddress,
|
||||
identity
|
||||
);
|
||||
this.setCachedToken(playlist._id, result.token);
|
||||
this.setCachedToken(playlist._id, result.token, playlist);
|
||||
settleSlot({
|
||||
token: result.token,
|
||||
serialNumber: identity.serialNumber,
|
||||
@@ -600,7 +748,7 @@ export class StalkerSessionService {
|
||||
} finally {
|
||||
// Only retire our own entry: a caller that started a later
|
||||
// authentication owns the map slot from then on.
|
||||
if (this.pendingAuth.get(playlist._id) === slot) {
|
||||
if (this.pendingAuth.get(playlist._id) === slotEntry) {
|
||||
this.pendingAuth.delete(playlist._id);
|
||||
}
|
||||
}
|
||||
@@ -630,6 +778,29 @@ export class StalkerSessionService {
|
||||
|
||||
const response = responseOrError as Record<string, unknown>;
|
||||
|
||||
// The complete set of portal auth failures — the plain-text bodies
|
||||
// (`Authorization failed.`, `Access denied.`, `Unauthorized
|
||||
// request.`) and their JSON-envelope forms. 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.
|
||||
if (
|
||||
isStalkerAuthFailureResponse(responseOrError) ||
|
||||
isStalkerAuthFailureMessage(response?.['message'])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTTP auth codes surviving 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 response?.['message'] === 'string' &&
|
||||
/HTTP Error 40[13]\b/.test(response['message'])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Convert response to string for pattern matching
|
||||
const responseStr = JSON.stringify(responseOrError).toLowerCase();
|
||||
|
||||
@@ -682,9 +853,11 @@ export class StalkerSessionService {
|
||||
|
||||
// Check for authorization failure in response
|
||||
if (this.isAuthorizationError(response)) {
|
||||
if (retryOnAuthFailure && playlist.isFullStalkerPortal) {
|
||||
// Retire the failed token so the retry re-authenticates
|
||||
this.retireFailedToken(playlist._id, token);
|
||||
// Retire the failed token even when not retrying (e.g.
|
||||
// watchdog pings): leaving it cached would hand a dead
|
||||
// session to the next caller.
|
||||
this.retireFailedToken(playlist._id, token);
|
||||
if (retryOnAuthFailure && isFullStalkerPortalPlaylist(playlist)) {
|
||||
// Retry once with fresh authentication
|
||||
return this.makeAuthenticatedRequest<T>(
|
||||
playlist,
|
||||
@@ -699,18 +872,21 @@ export class StalkerSessionService {
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Check if error indicates auth failure
|
||||
if (
|
||||
this.isAuthorizationError(error) &&
|
||||
retryOnAuthFailure &&
|
||||
playlist.isFullStalkerPortal
|
||||
) {
|
||||
// Retire the failed token and retry with new handshake
|
||||
if (this.isAuthorizationError(error)) {
|
||||
// Same rule as above: a failed session is retired even on
|
||||
// the no-retry path.
|
||||
this.retireFailedToken(playlist._id, token);
|
||||
return this.makeAuthenticatedRequest<T>(
|
||||
playlist,
|
||||
params,
|
||||
false
|
||||
);
|
||||
if (
|
||||
retryOnAuthFailure &&
|
||||
isFullStalkerPortalPlaylist(playlist)
|
||||
) {
|
||||
// Retry with a fresh handshake
|
||||
return this.makeAuthenticatedRequest<T>(
|
||||
playlist,
|
||||
params,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -148,7 +148,6 @@ describe('StalkerStore API compatibility smoke', () => {
|
||||
'addToRecentlyViewed',
|
||||
'removeFromRecentlyViewed',
|
||||
'fetchChannelEpg',
|
||||
'makeStalkerRequest',
|
||||
];
|
||||
|
||||
for (const methodName of expectedMethods) {
|
||||
|
||||
+3
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../models';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import { StalkerItvCacheService } from '../../stalker-itv-cache.service';
|
||||
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import {
|
||||
ResourceState,
|
||||
@@ -194,6 +195,7 @@ export function withStalkerContent() {
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
portalRepair = inject(StalkerPortalRepairService),
|
||||
translateService = inject(TranslateService),
|
||||
itvCache = inject(StalkerItvCacheService)
|
||||
) => {
|
||||
@@ -202,6 +204,7 @@ export function withStalkerContent() {
|
||||
const requestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
portalRepair,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
StalkerPortalActions,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
|
||||
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { StalkerEpgFeatureStoreContract } from '../stalker-store.contracts';
|
||||
import { executeStalkerRequest } from '../utils';
|
||||
@@ -101,6 +102,7 @@ export function withStalkerEpg() {
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
portalRepair = inject(StalkerPortalRepairService),
|
||||
runtime = inject(RuntimeCapabilitiesService),
|
||||
epgBridge = inject(EpgRuntimeBridgeService)
|
||||
) => {
|
||||
@@ -109,6 +111,7 @@ export function withStalkerEpg() {
|
||||
const requestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
portalRepair,
|
||||
};
|
||||
const supportsEpg = (): boolean => runtime.supportsEpg;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
isCrossOriginStalkerStream,
|
||||
STALKER_MAG_USER_AGENT,
|
||||
} from '../../stalker-live-playback.utils';
|
||||
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import {
|
||||
normalizeStalkerEntityId,
|
||||
@@ -54,6 +55,7 @@ export function withStalkerPlayer() {
|
||||
playlistService = inject(PlaylistsService),
|
||||
playerService = inject(PORTAL_PLAYER),
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
portalRepair = inject(StalkerPortalRepairService),
|
||||
snackBar = inject(MatSnackBar),
|
||||
translate = inject(TranslateService),
|
||||
ngrxStore = inject(Store)
|
||||
@@ -63,6 +65,7 @@ export function withStalkerPlayer() {
|
||||
const requestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
portalRepair,
|
||||
};
|
||||
|
||||
const createRequestPlaylist = (
|
||||
|
||||
+31
-18
@@ -1,7 +1,8 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { signalStore } from '@ngrx/signals';
|
||||
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { withStalkerPortal } from './with-stalker-portal.feature';
|
||||
|
||||
@@ -38,6 +39,7 @@ describe('withStalkerPortal', () => {
|
||||
ensureToken: jest.Mock;
|
||||
setActiveWatchdogPlaylist: jest.Mock;
|
||||
};
|
||||
let applyOverride: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
dbCreatePlaylist = jest.fn().mockResolvedValue(undefined);
|
||||
@@ -57,6 +59,7 @@ describe('withStalkerPortal', () => {
|
||||
ensureToken: jest.fn(),
|
||||
setActiveWatchdogPlaylist: jest.fn(),
|
||||
};
|
||||
applyOverride = jest.fn((playlist) => playlist);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -75,6 +78,10 @@ describe('withStalkerPortal', () => {
|
||||
provide: StalkerSessionService,
|
||||
useValue: stalkerSession,
|
||||
},
|
||||
{
|
||||
provide: StalkerPortalRepairService,
|
||||
useValue: { applyOverride },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -100,6 +107,29 @@ describe('withStalkerPortal', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('hands the repaired configuration to the watchdog and store on activation', async () => {
|
||||
// Route re-activation passes the stale NgRx meta; the repair
|
||||
// override must win here, or reopening the portal would stop or
|
||||
// repoint the repaired keepalive back to the broken configuration.
|
||||
const repaired = {
|
||||
...PLAYLIST,
|
||||
portalUrl: 'http://demo.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
};
|
||||
applyOverride.mockReturnValue(repaired);
|
||||
|
||||
await store.setCurrentPlaylist(PLAYLIST);
|
||||
|
||||
expect(applyOverride).toHaveBeenCalledWith(PLAYLIST);
|
||||
expect(stalkerSession.setActiveWatchdogPlaylist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
portalUrl: 'http://demo.example/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
})
|
||||
);
|
||||
expect(store.currentPlaylist()).toEqual(repaired);
|
||||
});
|
||||
|
||||
it('does not touch SQLite when the Electron bridge is partial', async () => {
|
||||
runtime.supportsStalkerPlaylistSqliteSync = false;
|
||||
|
||||
@@ -109,21 +139,4 @@ describe('withStalkerPortal', () => {
|
||||
expect(dbCreatePlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends Stalker requests through DataService without requiring the SQLite bridge', async () => {
|
||||
const dataService = TestBed.inject(DataService) as unknown as {
|
||||
sendIpcEvent: jest.Mock;
|
||||
};
|
||||
dataService.sendIpcEvent.mockResolvedValue({ js: { data: [] } });
|
||||
|
||||
await store.makeStalkerRequest(PLAYLIST, { action: 'get_profile' });
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
STALKER_REQUEST,
|
||||
expect.objectContaining({
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
params: { action: 'get_profile' },
|
||||
url: 'http://demo.example/stalker_portal/server/load.php',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+19
-44
@@ -3,12 +3,12 @@ import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withMethods,
|
||||
withProps,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { toStalkerSessionPlaylist } from '../utils';
|
||||
|
||||
@@ -40,57 +40,32 @@ export function withStalkerPortal() {
|
||||
const logger = createLogger('withStalkerPortal');
|
||||
return signalStoreFeature(
|
||||
withState<StalkerPortalState>(initialPortalState),
|
||||
withProps(
|
||||
(
|
||||
_store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
) => ({
|
||||
/**
|
||||
* Helper to make stalker requests with automatic token handling
|
||||
*/
|
||||
async makeStalkerRequest(
|
||||
playlist: PlaylistMeta,
|
||||
params: Record<string, string | number>
|
||||
) {
|
||||
// Get token if it's a full stalker portal
|
||||
let token: string | undefined;
|
||||
let serialNumber: string | undefined;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
try {
|
||||
const result = await stalkerSession.ensureToken(
|
||||
toStalkerSessionPlaylist(playlist)
|
||||
);
|
||||
token = result.token ?? undefined;
|
||||
serialNumber = result.serialNumber;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get stalker token', error);
|
||||
}
|
||||
}
|
||||
|
||||
return dataService.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params,
|
||||
token,
|
||||
serialNumber,
|
||||
});
|
||||
},
|
||||
})
|
||||
),
|
||||
// NOTE: the old `makeStalkerRequest` prop was removed — it was a
|
||||
// production-dead fourth copy of the portal-mode branch. All request
|
||||
// paths go through `executeStalkerRequest` (stores/utils), which
|
||||
// applies the shared predicate and the lazy portal repair.
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
portalRepair = inject(StalkerPortalRepairService),
|
||||
runtime = inject(RuntimeCapabilitiesService)
|
||||
) => ({
|
||||
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
|
||||
// A lazy repair may have corrected this playlist's
|
||||
// endpoint/mode while the NgRx meta stayed stale; route
|
||||
// re-activation must not hand the stale snapshot back to
|
||||
// the watchdog (it would stop or repoint the repaired
|
||||
// keepalive) or into the store state.
|
||||
const effectivePlaylist = playlist
|
||||
? portalRepair.applyOverride(playlist)
|
||||
: playlist;
|
||||
stalkerSession.setActiveWatchdogPlaylist(
|
||||
playlist
|
||||
? toStalkerSessionPlaylist(playlist)
|
||||
effectivePlaylist
|
||||
? toStalkerSessionPlaylist(effectivePlaylist)
|
||||
: undefined
|
||||
);
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
patchState(store, { currentPlaylist: effectivePlaylist });
|
||||
|
||||
// Ensure Stalker playlist exists in SQLite for playback positions
|
||||
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
|
||||
|
||||
+7
-2
@@ -16,6 +16,7 @@ import {
|
||||
StalkerVodSeriesSeason,
|
||||
} from '../../models';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { isStalkerSeriesFlag } from '../../stalker-vod.utils';
|
||||
import { StalkerSeriesFeatureStoreContract } from '../stalker-store.contracts';
|
||||
@@ -82,13 +83,15 @@ export function withStalkerSeries() {
|
||||
(
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
portalRepair = inject(StalkerPortalRepairService)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerSeriesStoreContext;
|
||||
const requestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
portalRepair,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -223,13 +226,15 @@ export function withStalkerSeries() {
|
||||
(
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
portalRepair = inject(StalkerPortalRepairService)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
Pick<StalkerSeriesStoreContext, 'currentPlaylist'>;
|
||||
const requestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
portalRepair,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+4
@@ -11,6 +11,10 @@ const PLAYLIST = {
|
||||
title: 'Demo Stalker',
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
// Explicit: with the flag undefined the shared predicate would fall back
|
||||
// to the URL shape, classify this as a full portal and route through the
|
||||
// (empty) session mock instead of the DataService mock under test.
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
const SNAPSHOT: StalkerVodSource = {
|
||||
|
||||
+4
-1
@@ -12,6 +12,7 @@ import {
|
||||
StalkerPortalActions,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StalkerVodSource } from '../../models';
|
||||
import { StalkerPortalRepairService } from '../../stalker-portal-repair.service';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
|
||||
import { StalkerPortalStoreContract } from '../stalker-store.contracts';
|
||||
@@ -50,13 +51,15 @@ export function withStalkerSnapshotRefresh() {
|
||||
(
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
portalRepair = inject(StalkerPortalRepairService)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerPortalStoreContract;
|
||||
const requestDeps: StalkerRequestDeps = {
|
||||
dataService,
|
||||
stalkerSession,
|
||||
portalRepair,
|
||||
};
|
||||
|
||||
const findFreshRow = async (
|
||||
|
||||
+67
@@ -33,6 +33,73 @@ describe('stalker-player-request.utils', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('resolves relative replies against arbitrary discovered installations', async () => {
|
||||
// Discovery can persist a nested endpoint (/cp/server/load.php);
|
||||
// the base must come from the endpoint's API suffix, not from a
|
||||
// fixed stalker_portal|c|portal segment allowlist.
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: '/media/video_5.mpg' },
|
||||
});
|
||||
|
||||
const streamUrl = await fetchStalkerPlaybackLink(
|
||||
{
|
||||
dataService: dataService as never,
|
||||
stalkerSession: stalkerSession as StalkerSessionService,
|
||||
},
|
||||
{
|
||||
playlist: {
|
||||
...PLAYLIST,
|
||||
portalUrl: 'http://demo.example/cp/server/load.php',
|
||||
} as PlaylistMeta,
|
||||
selectedContentType: 'vod',
|
||||
cmd: '/media/source.mpg',
|
||||
}
|
||||
);
|
||||
|
||||
expect(streamUrl).toBe('http://demo.example/cp/media/video_5.mpg');
|
||||
});
|
||||
|
||||
it('resolves relative create_link replies against the repaired endpoint', async () => {
|
||||
// A lazy repair can move the endpoint while the caller still holds
|
||||
// the activation-time playlist snapshot; the relative `js.cmd` must
|
||||
// resolve against the endpoint that actually answered.
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg /media/video_9.mpg' },
|
||||
});
|
||||
// Old row: root portal.php (base path ''). Repaired endpoint lives
|
||||
// under /stalker_portal — the resolver keeps that segment as the
|
||||
// base for root-relative replies, so the two resolve differently.
|
||||
const stalePlaylist = {
|
||||
...PLAYLIST,
|
||||
portalUrl: 'http://demo.example/portal.php',
|
||||
} as PlaylistMeta;
|
||||
const repaired = {
|
||||
...stalePlaylist,
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
} as PlaylistMeta;
|
||||
|
||||
const streamUrl = 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: stalePlaylist,
|
||||
selectedContentType: 'vod',
|
||||
cmd: '/media/source.mpg',
|
||||
}
|
||||
);
|
||||
|
||||
expect(streamUrl).toBe(
|
||||
'http://demo.example/stalker_portal/media/video_9.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds create_link requests and normalizes relative portal URLs', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: '/media/video_77.mpg' },
|
||||
|
||||
+35
-12
@@ -7,7 +7,10 @@ import {
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import { StalkerContentType } from '../stalker-store.contracts';
|
||||
import { executeStalkerRequest } from './stalker-request.utils';
|
||||
import {
|
||||
executeStalkerRequest,
|
||||
type StalkerPortalRepairApi,
|
||||
} from './stalker-request.utils';
|
||||
|
||||
export interface StalkerPlayerResponse {
|
||||
js?: {
|
||||
@@ -23,6 +26,7 @@ export interface StalkerPlayerResponse {
|
||||
export interface StalkerPlayerRequestDeps {
|
||||
dataService: DataService;
|
||||
stalkerSession: StalkerSessionService;
|
||||
portalRepair?: StalkerPortalRepairApi;
|
||||
}
|
||||
|
||||
export interface StalkerPlayableItemLike extends StalkerPortalItem {
|
||||
@@ -68,17 +72,28 @@ export function resolveStalkerPlaybackUrl(
|
||||
|
||||
try {
|
||||
const portalUrlObj = new URL(portalUrl);
|
||||
const pathParts = portalUrlObj.pathname.split('/');
|
||||
// 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 = '';
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +161,16 @@ export async function fetchStalkerPlaybackLink(
|
||||
throw new Error(response.js.error);
|
||||
}
|
||||
|
||||
// Applied AFTER the request on purpose: a lazy repair may have moved
|
||||
// the endpoint during this very call, and a relative `js.cmd`
|
||||
// (`/media/...`) must resolve against the endpoint that actually
|
||||
// answered — not the activation-time snapshot in options.playlist.
|
||||
const effectivePlaylist = deps.portalRepair
|
||||
? deps.portalRepair.applyOverride(options.playlist)
|
||||
: options.playlist;
|
||||
|
||||
const streamUrl = resolveStalkerPlaybackUrl(
|
||||
options.playlist.portalUrl ?? '',
|
||||
effectivePlaylist.portalUrl ?? '',
|
||||
options.cmd,
|
||||
response.js?.cmd ?? ''
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
executeStalkerRequest,
|
||||
type StalkerPortalRepairApi,
|
||||
type StalkerRequestDeps,
|
||||
} from './stalker-request.utils';
|
||||
|
||||
@@ -76,3 +77,141 @@ describe('executeStalkerRequest', () => {
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeStalkerRequest portal-mode fallback', () => {
|
||||
it('authenticates legacy rows with an undefined flag but a canonical URL', async () => {
|
||||
// The historical import predicate persisted nothing for some rows;
|
||||
// the shared predicate must fall back to the URL rule instead of
|
||||
// silently skipping authentication (the #850 failure shape).
|
||||
const deps = createDeps();
|
||||
const playlist = {
|
||||
_id: 'stalker-legacy',
|
||||
title: 'Legacy row',
|
||||
portalUrl: 'https://portal.example.test/server/load.php',
|
||||
macAddress: 'has-mac-address',
|
||||
} as PlaylistMeta;
|
||||
|
||||
await executeStalkerRequest(deps, playlist, CATEGORY_PARAMS);
|
||||
|
||||
expect(deps.stalkerSession.makeAuthenticatedRequest).toHaveBeenCalled();
|
||||
expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeStalkerRequest lazy portal repair', () => {
|
||||
const PLAYLIST = {
|
||||
_id: 'stalker-misclassified',
|
||||
title: 'Misclassified portal',
|
||||
portalUrl: 'https://portal.example.test/server/load.php',
|
||||
macAddress: 'has-mac-address',
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta;
|
||||
|
||||
function createRepair(
|
||||
overrides: Partial<StalkerPortalRepairApi> = {}
|
||||
): StalkerPortalRepairApi {
|
||||
return {
|
||||
applyOverride: jest.fn((playlist) => playlist),
|
||||
shouldAttemptRepair: jest.fn().mockReturnValue(false),
|
||||
repairPortal: jest.fn().mockResolvedValue(null),
|
||||
...overrides,
|
||||
} as StalkerPortalRepairApi;
|
||||
}
|
||||
|
||||
it('retries once against the repaired configuration after an auth-failure body', async () => {
|
||||
const deps = createDeps();
|
||||
deps.dataService.sendIpcEvent = jest
|
||||
.fn()
|
||||
.mockResolvedValue('Authorization failed.');
|
||||
const repaired = {
|
||||
...PLAYLIST,
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
deps.portalRepair = createRepair({
|
||||
shouldAttemptRepair: jest.fn(
|
||||
(_playlist, failure) => failure === 'Authorization failed.'
|
||||
),
|
||||
repairPortal: jest.fn().mockResolvedValue(repaired),
|
||||
});
|
||||
|
||||
const response = await executeStalkerRequest(
|
||||
deps,
|
||||
PLAYLIST,
|
||||
CATEGORY_PARAMS
|
||||
);
|
||||
|
||||
// The retry ran in full-portal mode against the repaired row.
|
||||
expect(
|
||||
deps.stalkerSession.makeAuthenticatedRequest
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isFullStalkerPortal: true }),
|
||||
CATEGORY_PARAMS
|
||||
);
|
||||
expect(response).toEqual({ js: [] });
|
||||
});
|
||||
|
||||
it('returns the raw response when the repair declines to change anything', async () => {
|
||||
const deps = createDeps();
|
||||
deps.dataService.sendIpcEvent = jest
|
||||
.fn()
|
||||
.mockResolvedValue('Authorization failed.');
|
||||
deps.portalRepair = createRepair({
|
||||
shouldAttemptRepair: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
|
||||
const response = await executeStalkerRequest(
|
||||
deps,
|
||||
PLAYLIST,
|
||||
CATEGORY_PARAMS
|
||||
);
|
||||
|
||||
expect(response).toBe('Authorization failed.');
|
||||
expect(deps.dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('repairs after a thrown transport error and rethrows when nothing changed', async () => {
|
||||
const deps = createDeps();
|
||||
const notFound = { message: 'HTTP Error: Not Found', status: 404 };
|
||||
deps.dataService.sendIpcEvent = jest.fn().mockRejectedValue(notFound);
|
||||
const repair = createRepair({
|
||||
shouldAttemptRepair: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
deps.portalRepair = repair;
|
||||
|
||||
await expect(
|
||||
executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS)
|
||||
).rejects.toBe(notFound);
|
||||
expect(repair.repairPortal).toHaveBeenCalledWith(PLAYLIST);
|
||||
});
|
||||
|
||||
it('applies an existing override before dispatching', async () => {
|
||||
const deps = createDeps();
|
||||
const repaired = {
|
||||
...PLAYLIST,
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
deps.portalRepair = createRepair({
|
||||
applyOverride: jest.fn().mockReturnValue(repaired),
|
||||
});
|
||||
|
||||
await executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS);
|
||||
|
||||
expect(
|
||||
deps.stalkerSession.makeAuthenticatedRequest
|
||||
).toHaveBeenCalled();
|
||||
expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never repairs on healthy responses', async () => {
|
||||
const deps = createDeps();
|
||||
const repair = createRepair();
|
||||
deps.portalRepair = repair;
|
||||
|
||||
await executeStalkerRequest(deps, PLAYLIST, CATEGORY_PARAMS);
|
||||
|
||||
expect(repair.shouldAttemptRepair).toHaveBeenCalledWith(PLAYLIST, {
|
||||
js: [],
|
||||
});
|
||||
expect(repair.repairPortal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,34 @@
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { Playlist, PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
isFullStalkerPortalPlaylist,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
STALKER_REQUEST,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
|
||||
/**
|
||||
* The slice of `StalkerPortalRepairService` the request pipeline needs.
|
||||
* Declared here (instead of importing the service) so the service can live
|
||||
* at the lib root and depend on these utils without a cycle.
|
||||
*/
|
||||
export interface StalkerPortalRepairApi {
|
||||
/** Applies a completed repair; returns the SAME reference when no-op. */
|
||||
applyOverride<T extends PlaylistMeta>(playlist: T): T;
|
||||
/** Whether this failure shape justifies probing the portal at all. */
|
||||
shouldAttemptRepair(playlist: PlaylistMeta, failure: unknown): boolean;
|
||||
/**
|
||||
* Probes and persists a proven-different configuration; null when
|
||||
* nothing may change.
|
||||
*/
|
||||
repairPortal(playlist: PlaylistMeta): Promise<PlaylistMeta | null>;
|
||||
}
|
||||
|
||||
export interface StalkerRequestDeps {
|
||||
dataService: DataService;
|
||||
stalkerSession: StalkerSessionService;
|
||||
/** Optional lazy portal-mode repair; wired by the store feature slices. */
|
||||
portalRepair?: StalkerPortalRepairApi;
|
||||
}
|
||||
|
||||
export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist {
|
||||
@@ -14,12 +38,19 @@ export function toStalkerSessionPlaylist(playlist: PlaylistMeta): Playlist {
|
||||
} as Playlist;
|
||||
}
|
||||
|
||||
export async function executeStalkerRequest<T>(
|
||||
/**
|
||||
* Routes one Stalker request according to the playlist's portal mode:
|
||||
* full portals go through the authenticated session (handshake + Bearer
|
||||
* token + retry), token-free panels are called directly. The mode comes
|
||||
* from the shared `isFullStalkerPortalPlaylist` predicate — the single
|
||||
* rule every portal-mode consumer uses.
|
||||
*/
|
||||
async function dispatchStalkerRequest<T>(
|
||||
deps: StalkerRequestDeps,
|
||||
playlist: PlaylistMeta,
|
||||
params: Record<string, string | number>
|
||||
): Promise<T> {
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
if (isFullStalkerPortalPlaylist(playlist)) {
|
||||
return deps.stalkerSession.makeAuthenticatedRequest<T>(
|
||||
toStalkerSessionPlaylist(playlist),
|
||||
params
|
||||
@@ -32,3 +63,48 @@ export async function executeStalkerRequest<T>(
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single choke point for Stalker API calls. On top of the mode routing it
|
||||
* hooks the lazy portal repair: when a request fails in a way only a wrong
|
||||
* persisted endpoint/mode produces (plain-text `Authorization failed.`
|
||||
* bodies on token-less requests, HTTP 404 on a vanished endpoint, terminal
|
||||
* handshake failures), the repair service re-probes the portal once per
|
||||
* session and — only when a different configuration is PROVEN to work —
|
||||
* the request is retried against it. Healthy portals never probe.
|
||||
*/
|
||||
export async function executeStalkerRequest<T>(
|
||||
deps: StalkerRequestDeps,
|
||||
playlist: PlaylistMeta,
|
||||
params: Record<string, string | number>
|
||||
): Promise<T> {
|
||||
const effective = deps.portalRepair
|
||||
? deps.portalRepair.applyOverride(playlist)
|
||||
: playlist;
|
||||
|
||||
try {
|
||||
const response = await dispatchStalkerRequest<T>(
|
||||
deps,
|
||||
effective,
|
||||
params
|
||||
);
|
||||
|
||||
if (deps.portalRepair?.shouldAttemptRepair(effective, response)) {
|
||||
const repaired = await deps.portalRepair.repairPortal(effective);
|
||||
if (repaired) {
|
||||
return dispatchStalkerRequest<T>(deps, repaired, params);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (deps.portalRepair?.shouldAttemptRepair(effective, error)) {
|
||||
const repaired = await deps.portalRepair.repairPortal(effective);
|
||||
if (repaired) {
|
||||
return dispatchStalkerRequest<T>(deps, repaired, params);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,15 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
executeStalkerRequest,
|
||||
StalkerPortalRepairService,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
Playlist,
|
||||
ResolvedPortalPlayback,
|
||||
STALKER_REQUEST,
|
||||
StalkerPortalActions,
|
||||
VodDetailsItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -97,6 +99,7 @@ export class StalkerSearchComponent {
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly portalRepair = inject(StalkerPortalRepairService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly logger = createLogger('StalkerSearch');
|
||||
@@ -191,30 +194,19 @@ export class StalkerSearchComponent {
|
||||
...(contentType === 'vod' ? { genre: '0' } : {}),
|
||||
};
|
||||
|
||||
// Full portals need the handshake token. The persisted flag can
|
||||
// be missing on the active-playlist meta object, so fall back
|
||||
// to URL detection — otherwise the portal answers
|
||||
// "Authorization failed." and the search looks empty.
|
||||
const isFullPortal =
|
||||
(playlist as Playlist).isFullStalkerPortal ??
|
||||
this.stalkerSession.isFullStalkerPortal(portalUrl);
|
||||
|
||||
const response = isFullPortal
|
||||
? await this.stalkerSession.makeAuthenticatedRequest<StalkerSearchResponse>(
|
||||
{
|
||||
...(playlist as Playlist),
|
||||
isFullStalkerPortal: true,
|
||||
},
|
||||
requestParams
|
||||
)
|
||||
: await this.dataService.sendIpcEvent<StalkerSearchResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params: requestParams,
|
||||
}
|
||||
);
|
||||
// executeStalkerRequest owns the portal-mode decision (shared
|
||||
// predicate with URL fallback for legacy rows) and the lazy
|
||||
// portal repair, so search cannot drift from the catalog paths.
|
||||
const response =
|
||||
await executeStalkerRequest<StalkerSearchResponse>(
|
||||
{
|
||||
dataService: this.dataService,
|
||||
stalkerSession: this.stalkerSession,
|
||||
portalRepair: this.portalRepair,
|
||||
},
|
||||
playlist,
|
||||
requestParams
|
||||
);
|
||||
const items = response.js?.data || [];
|
||||
return items.map((item: StalkerVodSource) =>
|
||||
this.processItemUrls(item, portalUrl)
|
||||
|
||||
@@ -1290,6 +1290,9 @@ describe('PlaylistsService', () => {
|
||||
store.current = target;
|
||||
}
|
||||
}),
|
||||
dbDeletePlaylist: jest.fn(async () => {
|
||||
store.current = undefined as unknown as Playlist;
|
||||
}),
|
||||
};
|
||||
return { store, electron };
|
||||
}
|
||||
@@ -1479,6 +1482,87 @@ describe('PlaylistsService', () => {
|
||||
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('serializes deletion behind queued writes so nothing resurrects the row', async () => {
|
||||
const { store, electron } = createStatefulElectronStore(
|
||||
createBasePlaylist('portal-delete-race')
|
||||
);
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
// A conditional transform (the repair's write path) is queued
|
||||
// when the user deletes the playlist: the delete must run AFTER
|
||||
// the queued write, leaving the row deleted — not upserted back.
|
||||
await Promise.all([
|
||||
firstValueFrom(
|
||||
service.transformPlaylistMeta(
|
||||
'portal-delete-race',
|
||||
(current) => ({
|
||||
...current,
|
||||
portalUrl: 'http://x/portal.php',
|
||||
})
|
||||
)
|
||||
),
|
||||
firstValueFrom(service.deletePlaylist('portal-delete-race')),
|
||||
]);
|
||||
|
||||
expect(store.current).toBeUndefined();
|
||||
const upsertOrder =
|
||||
electron.dbUpsertAppPlaylist.mock.invocationCallOrder[0];
|
||||
const deleteOrder =
|
||||
electron.dbDeletePlaylist.mock.invocationCallOrder[0];
|
||||
expect(deleteOrder).toBeGreaterThan(upsertOrder);
|
||||
});
|
||||
|
||||
it('transformPlaylistMeta aborts without writing when the transform returns null', async () => {
|
||||
const { store, electron } = createStatefulElectronStore(
|
||||
createBasePlaylist('portal-meta-abort')
|
||||
);
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
const writesBefore = electron.dbUpsertAppPlaylist.mock.calls.length;
|
||||
|
||||
const result = await firstValueFrom(
|
||||
service.transformPlaylistMeta('portal-meta-abort', () => null)
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(electron.dbUpsertAppPlaylist.mock.calls.length).toBe(
|
||||
writesBefore
|
||||
);
|
||||
});
|
||||
|
||||
it('transformPlaylistMeta persists the transformed row and serializes with queued edits', async () => {
|
||||
const { store, electron } = createStatefulElectronStore(
|
||||
createBasePlaylist('portal-meta-write')
|
||||
);
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
// A queued edit commits first; the conditional transform then
|
||||
// sees ITS result — the property the Stalker portal repair
|
||||
// relies on to never overwrite a user edit racing the probe.
|
||||
await Promise.all([
|
||||
firstValueFrom(
|
||||
service.updatePlaylistMeta({
|
||||
_id: 'portal-meta-write',
|
||||
title: 'Edited Title',
|
||||
} as never)
|
||||
),
|
||||
firstValueFrom(
|
||||
service.transformPlaylistMeta(
|
||||
'portal-meta-write',
|
||||
(current) =>
|
||||
current.title === 'Edited Title'
|
||||
? { ...current, portalUrl: 'http://x/portal.php' }
|
||||
: null
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
expect(store.current.title).toBe('Edited Title');
|
||||
expect(store.current.portalUrl).toBe('http://x/portal.php');
|
||||
});
|
||||
|
||||
it('applies overlapping favorites transforms atomically', async () => {
|
||||
const { store, electron } = createStatefulElectronStore(
|
||||
createBasePlaylist('portal-transform-race')
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Channel,
|
||||
DbStores,
|
||||
extractStalkerItemId,
|
||||
isFullStalkerPortalUrl,
|
||||
isM3uRecentlyViewedItem,
|
||||
M3uFavoriteChannel,
|
||||
M3uRecentlyViewedItem,
|
||||
@@ -254,13 +255,10 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
const portalUrl = playlist.portalUrl ?? playlist.url ?? '';
|
||||
const isFullPortal =
|
||||
portalUrl.includes('/stalker_portal') ||
|
||||
portalUrl.includes('/server/load.php');
|
||||
|
||||
return {
|
||||
...playlist,
|
||||
isFullStalkerPortal: isFullPortal,
|
||||
isFullStalkerPortal: isFullStalkerPortalUrl(portalUrl),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -475,17 +473,27 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
deletePlaylist(playlistId: string): Observable<{ success: boolean }> {
|
||||
const delete$: Observable<unknown> = this.isElectronStorageAvailable
|
||||
? this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
if (!electron) {
|
||||
return undefined;
|
||||
}
|
||||
// Deletion goes through the SAME per-playlist queue as every write:
|
||||
// a queued mutation (e.g. the Stalker portal repair's conditional
|
||||
// transform) landing after an unserialized delete would upsert the
|
||||
// row back and resurrect the playlist.
|
||||
const delete$: Observable<unknown> = this.serializePlaylistWrite(
|
||||
playlistId,
|
||||
async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
await electron.dbDeletePlaylist(playlistId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await electron.dbDeletePlaylist(playlistId);
|
||||
return undefined;
|
||||
})
|
||||
: this.dbService.delete(DbStores.Playlists, playlistId);
|
||||
return firstValueFrom(
|
||||
this.dbService.delete(DbStores.Playlists, playlistId)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return delete$.pipe(
|
||||
switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))),
|
||||
@@ -769,6 +777,41 @@ export class PlaylistsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an atomic, conditional meta mutation: the transform runs on
|
||||
* the freshly read row INSIDE the per-playlist write queue and may
|
||||
* return null to abort without writing. Callers use this when the
|
||||
* decision to write depends on the row's CURRENT state — e.g. the lazy
|
||||
* Stalker portal repair verifying the row still carries the
|
||||
* configuration it probed; a plain read-check-then-update pair would
|
||||
* race a user edit already queued but not yet committed.
|
||||
*/
|
||||
transformPlaylistMeta(
|
||||
playlistId: string,
|
||||
transform: (current: Playlist) => Playlist | null
|
||||
): Observable<Playlist | null> {
|
||||
if (!playlistId) {
|
||||
throw new Error('Playlist ID is required');
|
||||
}
|
||||
|
||||
return this.serializePlaylistWrite(playlistId, async () => {
|
||||
const playlist = await firstValueFrom(
|
||||
this.getPlaylistById(playlistId)
|
||||
);
|
||||
if (!playlist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextPlaylist = transform(playlist);
|
||||
if (nextPlaylist === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.persistPlaylistMutation(nextPlaylist);
|
||||
return nextPlaylist;
|
||||
});
|
||||
}
|
||||
|
||||
updateManyPlaylists(playlists: Playlist[]) {
|
||||
if (playlists.length === 0) {
|
||||
return of([]);
|
||||
|
||||
@@ -40,6 +40,7 @@ export * from './lib/security-policy-error.utils';
|
||||
export * from './lib/settings.interface';
|
||||
export * from './lib/stalker-cmd-encoding.util';
|
||||
export * from './lib/stalker-portal-actions.enum';
|
||||
export * from './lib/stalker-portal-mode.util';
|
||||
export * from './lib/store-keys.enum';
|
||||
export * from './lib/stream-format.enum';
|
||||
export * from './lib/catalog-title-match.interface';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
isFullStalkerPortalPlaylist,
|
||||
isFullStalkerPortalUrl,
|
||||
} from './stalker-portal-mode.util';
|
||||
|
||||
describe('isFullStalkerPortalUrl', () => {
|
||||
it.each([
|
||||
// The canonical Ministra endpoint the old IMPORT predicate missed —
|
||||
// the root cause of "portal added, no content" (#850).
|
||||
'http://portal.example/server/load.php',
|
||||
// /stalker_portal without a trailing slash, which the old RUNTIME
|
||||
// predicate (`includes('/stalker_portal/')`) missed.
|
||||
'http://portal.example/stalker_portal',
|
||||
'http://portal.example/stalker_portal/c',
|
||||
'http://portal.example/stalker_portal/server/load.php',
|
||||
])('classifies %s as a full portal URL', (url) => {
|
||||
expect(isFullStalkerPortalUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'http://portal.example/portal.php',
|
||||
'http://portal.example/c',
|
||||
'http://portal.example',
|
||||
'',
|
||||
])('classifies %s as a non-full portal URL', (url) => {
|
||||
expect(isFullStalkerPortalUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFullStalkerPortalPlaylist', () => {
|
||||
it('trusts an explicit true flag even for a portal.php URL', () => {
|
||||
// Discovery can prove a portal.php panel enforces the token; the
|
||||
// observed behavior must beat the URL shape.
|
||||
expect(
|
||||
isFullStalkerPortalPlaylist({
|
||||
isFullStalkerPortal: true,
|
||||
portalUrl: 'http://portal.example/portal.php',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('trusts an explicit false flag even for a canonical URL', () => {
|
||||
expect(
|
||||
isFullStalkerPortalPlaylist({
|
||||
isFullStalkerPortal: false,
|
||||
portalUrl: 'http://portal.example/server/load.php',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the portalUrl shape when the flag is undefined', () => {
|
||||
expect(
|
||||
isFullStalkerPortalPlaylist({
|
||||
portalUrl: 'http://portal.example/server/load.php',
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFullStalkerPortalPlaylist({
|
||||
portalUrl: 'http://portal.example/portal.php',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the legacy url field when portalUrl is absent', () => {
|
||||
expect(
|
||||
isFullStalkerPortalPlaylist({
|
||||
url: 'http://portal.example/stalker_portal/c',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a playlist with no URL at all as a simple portal', () => {
|
||||
expect(isFullStalkerPortalPlaylist({})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Single source of truth for the "full Stalker portal" decision.
|
||||
*
|
||||
* A full portal is one that requires the handshake + Bearer-token auth
|
||||
* lifecycle (canonical Stalker/Ministra middleware); a simple portal is a
|
||||
* reseller-style `portal.php` panel that answers without any auth. The
|
||||
* persisted `Playlist.isFullStalkerPortal` flag is authoritative — since
|
||||
* endpoint discovery it records OBSERVED behavior, not a URL guess. The URL
|
||||
* shape is only a fallback for legacy rows that predate the flag.
|
||||
*
|
||||
* History: three predicates used to coexist (import checked
|
||||
* `/stalker_portal`, runtime checked `/stalker_portal/` OR
|
||||
* `/server/load.php`, the legacy-repair migration checked a third variant)
|
||||
* and their drift misclassified canonical `…/server/load.php` portals as
|
||||
* token-free at import (#850, #686, #755). Every consumer must go through
|
||||
* these helpers so the rule cannot fork again.
|
||||
*/
|
||||
|
||||
/**
|
||||
* URL shapes that identify a canonical (full) Stalker portal endpoint.
|
||||
* Union of the historical import/runtime/migration variants: matches
|
||||
* `/stalker_portal` with or without a trailing slash and the root-level
|
||||
* `/server/load.php` used by Ministra installations.
|
||||
*/
|
||||
export function isFullStalkerPortalUrl(url: string): boolean {
|
||||
return url.includes('/stalker_portal') || url.includes('/server/load.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a playlist should use the full-portal auth lifecycle
|
||||
* (handshake, Bearer token, watchdog).
|
||||
*
|
||||
* The persisted flag wins when present; rows restored from older backups can
|
||||
* carry `undefined` even after the one-shot metadata migration ran, so those
|
||||
* fall back to the URL rule instead of being mislabelled as legacy panels.
|
||||
*/
|
||||
export function isFullStalkerPortalPlaylist(playlist: {
|
||||
isFullStalkerPortal?: boolean;
|
||||
portalUrl?: string;
|
||||
url?: string;
|
||||
}): boolean {
|
||||
if (playlist.isFullStalkerPortal !== undefined) {
|
||||
return Boolean(playlist.isFullStalkerPortal);
|
||||
}
|
||||
|
||||
return isFullStalkerPortalUrl(playlist.portalUrl ?? playlist.url ?? '');
|
||||
}
|
||||
Reference in new issue
Block a user