feat(stalker): protocol-correct auth lifecycle

Portals that answer get_profile with status 2 ("login/password required")
never authenticated: the status was treated as success and the do_auth path
had no callers. Parse js.status (numeric or stringified), run do_auth with the
credentials the import dialog now collects, and retry get_profile with
auth_second_step=1.

- Surface the portal's own msg/block_msg (markup-stripped) in the import
  dialog and the workspace category error state instead of a generic message.
- Classify the plain-text failure bodies at the Electron transport boundary as
  a structured marker rather than by JSON.stringify regex.
- Re-present the persisted stalkerToken in the handshake (which is
  idempotent), skipping get_profile when it comes back unchanged and not
  flagged not_valid, and write a renegotiated session back. The watchdog
  cadence is persisted with the token because that reuse skips the only
  response carrying it.
- Take the watchdog cadence from the profile (watchdog_timeout + timeslot),
  defaulting to the documented 120 s instead of pinging every 25 s.

Splits stalker-session.service.ts into an auth API, a watchdog controller, a
portal-error module and a response classifier; the service is now a facade and
drops off the max-lines baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-03 18:41:54 +02:00
1 parent e197409b10
commit 28429580a5
51 files changed
+3626 -656

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
type: feature
area: stalker
---
Stalker portals that ask for a login and password now work: the import dialog
gained username/password fields and the app completes the portal's do_auth
step. When a portal refuses access, its own explanation is shown instead of a
generic error, saved sessions resume without re-authenticating, and the
keep-alive ping follows the portal's cadence.
+10
View File
@@ -1111,6 +1111,16 @@ engine` (restart required) or
- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes, routing by the observed portal MODE rather than the URL shape (full mode: handshake+`get_profile`; simple mode: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields), and re-routing when a lazy repair changes the mode mid-request. Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog").
- Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once expired); account details remain behind ⋮ → Account info. `DashboardSourceExpiryService` (`libs/workspace/dashboard/data-access/`) gathers the facts: Xtream from `PortalStatusService.checkPortalStatusDetails()` (the switcher's cached status check, now carrying `exp_date`), Stalker from the persisted `stalkerAccountInfo` snapshot — it lives in the playlist payload, not on meta rows, so each Stalker source costs one memoized full-playlist read.
**Stalker Session Authentication**:
- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-watchdog.controller.ts`, `stalker-portal-error.ts` and `stalker-response-classification.ts`.
- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = blocked, `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it.
- Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code.
- Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections.
- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches the playlist — an edited MAC/serial must never inherit the previous session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start.
- Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting.
- Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle").
**Favorites and Recently Viewed**:
- Per-playlist favorites and global favorites
@@ -6,6 +6,8 @@
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import {
classifyStalkerAuthFailureBody,
createStalkerAuthFailureMarker,
PortalDebugEvent,
STALKER_REQUEST,
buildStalkerIdentityRequestContext,
@@ -111,6 +113,18 @@ ipcMain.handle(
throw httpError;
}
// The portal answers auth failures with HTTP 200 and a plain
// text/html body ("Authorization failed.", "Access denied.",
// "Unauthorized request."). Classify them here so the renderer
// receives a structured marker instead of pattern-matching raw
// response text.
const authFailureBody = classifyStalkerAuthFailureBody(
response.data
);
const responseData = authFailureBody
? createStalkerAuthFailureMarker(authFailureBody)
: response.data;
// Return the response data
if (
params.action === 'create_link' &&
@@ -136,12 +150,12 @@ ipcMain.handle(
durationMs: Date.now() - startedAt,
status: 'success',
request: debugRequest,
response: response.data,
response: responseData,
};
emitPortalDebugEvent(debugEvent);
}
return response.data;
return responseData;
} catch (error) {
if (payload.requestId) {
const debugEvent: PortalDebugEvent = {
+243 -21
View File
@@ -48,6 +48,11 @@ import {
* `--project=chromium` alone (`.github/workflows/e2e-tests.yaml`). If a local
* all-project run shows a lone auth failure that passes on rerun, this is why;
* `--project=chromium` reproduces CI exactly.
*
* Most tests here tolerate that reset anyway — they re-authenticate, or assert
* that a NEW token appears. The token-reuse test cannot: its assertion IS that
* the portal session survives, so it uses per-project MACs held outside
* `OWNED_MACS`. See `AUTH_REUSE_MACS`.
*/
test.describe.configure({ mode: 'serial' });
@@ -82,6 +87,13 @@ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
*/
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
/**
* Login-required scenario MAC — get_profile answers status 2 until do_auth
* completes with non-empty credentials (empty credentials return {js:false},
* as the operator billing script would).
*/
const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08';
/**
* Dedicated MACs for the full-portal authentication tests. Mock state is keyed
* by MAC, so keeping these distinct from the content scenarios above means an
@@ -90,6 +102,25 @@ const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
*/
const AUTH_FLOW_MAC = '00:1A:79:AD:00:01';
const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03';
/**
* The token-reuse test needs the portal session to SURVIVE untouched between
* its import and its reload — that persistence is the whole assertion. Every
* other test here is reset-tolerant (they either re-authenticate or assert a
* new token), so they can share `OWNED_MACS`, which each `beforeEach` clears.
*
* `mode: 'serial'` only serializes within one project; the browser projects
* still run concurrently, so a sibling project's reset would rotate this
* session mid-test. Hence one MAC per project, and deliberately NOT in
* `OWNED_MACS`: nothing may reset them. Leftover state from an earlier run is
* harmless — the import negotiates and adopts its own token first.
*/
const AUTH_REUSE_MACS: Record<string, string> = {
chromium: '00:1A:79:AD:01:01',
firefox: '00:1A:79:AD:01:02',
webkit: '00:1A:79:AD:01:03',
};
const AUTH_REUSE_FALLBACK_MAC = '00:1A:79:AD:01:04';
/**
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
* it, so no token is ever adopted and content requests fail permanently.
@@ -138,6 +169,7 @@ const OWNED_MACS = [
EMBEDDED_SERIES_MAC,
LEGACY_PAGINATION_MAC,
STATIC_CMD_MAC,
LOGIN_REQUIRED_MAC,
AUTH_FLOW_MAC,
AUTH_REAUTH_MAC,
AUTH_REJECTED_MAC,
@@ -212,9 +244,21 @@ async function addStalkerPortal(
*/
async function addFullStalkerPortal(
page: Page,
options: { name?: string; mac: string; expectContent?: boolean }
options: {
name?: string;
mac: string;
expectContent?: boolean;
username?: string;
password?: string;
}
): Promise<void> {
const { name = 'Full Stalker Portal', mac, expectContent = true } = options;
const {
name = 'Full Stalker Portal',
mac,
expectContent = true,
username,
password,
} = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
@@ -224,6 +268,12 @@ async function addFullStalkerPortal(
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
if (username !== undefined) {
await setInputValue(dialog.locator('input#username'), username);
}
if (password !== undefined) {
await setInputValue(dialog.locator('input#password'), password);
}
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
await expect(addButton).toBeEnabled({ timeout: 10_000 });
@@ -991,12 +1041,171 @@ test.describe('@stalker full portal authentication', () => {
.toBe(true);
});
test('never surfaces the portal plain-text auth failure as content', async ({
test('completes the documented login flow behind get_profile status 2', async ({
page,
}) => {
const requests = recordPortalRequests(page);
// The second auth step must only be claimed on the retry AFTER
// do_auth — a client that always sends auth_second_step=1 works
// against the mock but violates the documented protocol.
const profileSteps: string[] = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.endsWith('/stalker')) {
return;
}
if (url.searchParams.get('action') !== 'get_profile') {
return;
}
profileSteps.push(url.searchParams.get('auth_second_step') ?? '');
});
await addFullStalkerPortal(page, {
mac: LOGIN_REQUIRED_MAC,
username: 'user',
password: 'secret',
});
// The mock only adopts the token (and answers content) after do_auth
// completed with non-empty credentials, so rendered categories prove
// the whole status 2 -> do_auth -> profile retry chain ran.
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 30_000,
});
const actions = requests.map((entry) => entry.action);
expect(actions).toContain('do_auth');
expect(actions.indexOf('get_profile')).toBeLessThan(
actions.indexOf('do_auth')
);
expect(actions.lastIndexOf('get_profile')).toBeGreaterThan(
actions.indexOf('do_auth')
);
expect(profileSteps[0]).toBe('0');
expect(profileSteps).toContain('1');
// Content only flows once the token is adopted, which the mock does
// only after do_auth completed. Look AFTER the last get_profile:
// discovery's classification probe is itself a token-less
// `get_genres`, so the first CONTENT_ACTIONS hit is that probe.
const contentRequest = requests
.slice(actions.lastIndexOf('get_profile') + 1)
.find((entry) => CONTENT_ACTIONS.includes(entry.action));
expect(contentRequest).toBeDefined();
expect(contentRequest?.token).toBeTruthy();
});
test('explains a login-required portal and recovers once credentials are entered', async ({
page,
}) => {
// First attempt without credentials: the import must fail with the
// portal's actual reason, not a generic "check URL and MAC" error.
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), 'Login Portal');
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(
dialog.locator('input#macAddress'),
LOGIN_REQUIRED_MAC
);
const addButton = dialog.getByRole('button', {
name: 'Add',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(
page.getByText(/requires a login and password/i)
).toBeVisible({ timeout: 15_000 });
// The dialog stays open so the user can add the credentials.
await expect(dialog).toBeVisible();
// Second attempt with credentials in the same dialog succeeds.
await setInputValue(dialog.locator('input#username'), 'user');
await setInputValue(dialog.locator('input#password'), 'secret');
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden({ timeout: 30_000 });
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 30_000,
});
});
test('reuses the persisted token after a reload instead of re-running get_profile', async ({
page,
}, testInfo) => {
const requests = recordPortalRequests(page);
const mac =
AUTH_REUSE_MACS[testInfo.project.name] ?? AUTH_REUSE_FALLBACK_MAC;
await addFullStalkerPortal(page, { mac });
await expect
.poll(
() =>
requests.some(
(entry) =>
CONTENT_ACTIONS.includes(entry.action) &&
entry.token
),
{ timeout: 30_000 }
)
.toBe(true);
const sessionToken = requests.find(
(entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token
)?.token;
expect(sessionToken).toBeTruthy();
const requestCountBeforeReload = requests.length;
await page.reload();
// Wait on the UI rather than the request log: a reload restores the
// route, boots the app and re-authenticates before the first content
// request goes out, and rendered categories prove that whole chain
// finished (the portal only answers content for an adopted token).
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 60_000,
});
const afterReload = requests.slice(requestCountBeforeReload);
// Content came back under the SAME token, negotiated by re-presenting
// the persisted one in the handshake.
expect(
afterReload.filter(
(entry) =>
CONTENT_ACTIONS.includes(entry.action) &&
entry.token === sessionToken
).length
).toBeGreaterThan(0);
// The handshake re-presented the persisted token...
const reloadHandshake = afterReload.find(
(entry) => entry.action === 'handshake'
);
expect(reloadHandshake?.token).toBe(sessionToken);
// ...and because the portal returned it unchanged (idempotent
// handshake, still-adopted session), the profile round trip was
// skipped entirely.
expect(
afterReload.filter((entry) => entry.action === 'get_profile')
).toHaveLength(0);
});
test('refuses a rejected portal at import and never renders its plain-text failure', async ({
page,
request,
}) => {
const requests = recordPortalRequests(page);
// A MAC outside the Infomir OUI makes the strict endpoint answer
// get_profile with a bare {status:1}, so no token is ever adopted and
// every content request keeps returning the plain-text failure. Unlike
@@ -1014,25 +1223,38 @@ test.describe('@stalker full portal authentication', () => {
).json();
expect(failureBody.payload).toBe('Authorization failed.');
await addFullStalkerPortal(page, {
mac: AUTH_REJECTED_MAC,
expectContent: false,
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), 'Rejected Portal');
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(
dialog.locator('input#macAddress'),
AUTH_REJECTED_MAC
);
const addButton = dialog.getByRole('button', {
name: 'Add',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
// The app must have actually hit the failing portal...
await expect
.poll(
() =>
requests.filter((entry) =>
CONTENT_ACTIONS.includes(entry.action)
).length,
{ timeout: 30_000 }
)
.toBeGreaterThan(0);
// `status: 1` means the portal refused this device, so the import must
// stop there instead of persisting a portal that can never load. (It
// used to be treated as success whenever the portal sent no msg text,
// which imported a dead source.)
await expect(page.getByText(/refused access/i)).toBeVisible({
timeout: 15_000,
});
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/stalker/);
// ...and must never render the raw portal response as content. A
// portal answers auth failures with HTTP 200 + plain text, so an app
// that trusts the status code would happily paint these strings.
// And the raw portal response is never painted as UI. A portal answers
// auth failures with HTTP 200 + plain text, so an app that trusts the
// status code would happily render these strings.
await expect(page.locator('body')).not.toContainText(
'Authorization failed.'
);
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "رابط الخادم",
"URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com/stalker_portal)",
"ADD": "إضافة",
"VALIDATING": "جارٍ التحقق من البوابة..."
"VALIDATING": "جارٍ التحقق من البوابة...",
"CREDENTIALS_HINT": "مطلوب فقط عندما تطلب البوابة اسم مستخدم وكلمة مرور",
"AUTH_FAILED": "فشلت المصادقة مع البوابة. تحقق من الرابط وعنوان MAC.",
"LOGIN_REQUIRED": "تتطلب هذه البوابة اسم مستخدم وكلمة مرور. املأ حقلي اسم المستخدم وكلمة المرور وأعد المحاولة.",
"LOGIN_REJECTED": "رفضت البوابة اسم المستخدم وكلمة المرور.",
"PORTAL_REFUSED": "رفضت البوابة الوصول لهذا الجهاز.",
"PORTAL_MESSAGE": "أفادت البوابة: {{message}}"
},
"FILTER_BY_NAME": "التصفية حسب الاسم",
"FILTER_AND_SORT": "تصفية وفرز",
@@ -957,6 +963,7 @@
"TITLE": "لم يتم تحديد فئة",
"DESCRIPTION": "يرجى اختيار فئة لعرض المحتوى"
},
"STALKER_LOGIN_REQUIRED": "تتطلب البوابة اسم مستخدم وكلمة مرور. أعد استيراد البوابة واملأ حقلي اسم المستخدم وكلمة المرور.",
"PLAYLIST_SETTINGS": "إعدادات القائمة",
"HOME": "الرئيسية",
"DELETE": "حذف"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "رابط الخادم",
"URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com/c ولا https://example.com/stalker_portal/c)",
"ADD": "زيد",
"VALIDATING": "جاري التحقق من البوابة..."
"VALIDATING": "جاري التحقق من البوابة...",
"CREDENTIALS_HINT": "خاصين غير إذا البوابة كتطلب اسم المستخدم وكلمة المرور",
"AUTH_FAILED": "فشل تسجيل الدخول للبوابة. تحقق من الرابط وعنوان MAC.",
"LOGIN_REQUIRED": "هاد البوابة كتطلب اسم المستخدم وكلمة المرور. عمّر خانات اسم المستخدم وكلمة المرور وحاول مرة أخرى.",
"LOGIN_REJECTED": "البوابة رفضات اسم المستخدم وكلمة المرور.",
"PORTAL_REFUSED": "البوابة رفضات الوصول لهاد الجهاز.",
"PORTAL_MESSAGE": "البوابة قالت: {{message}}"
},
"FILTER_BY_NAME": "فلتر بالاسم",
"FILTER_AND_SORT": "فلتر وترتيب",
@@ -957,6 +963,7 @@
"TITLE": "ما كاين حتى فئة متختارة",
"DESCRIPTION": "عفاك اختار فئة باش تشوف المحتوى"
},
"STALKER_LOGIN_REQUIRED": "البوابة كتطلب اسم المستخدم وكلمة المرور. عاود استيراد البوابة وعمّر خانات اسم المستخدم وكلمة المرور.",
"PLAYLIST_SETTINGS": "إعدادات قائمة التشغيل",
"HOME": "الرئيسية",
"DELETE": "حذف"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL сервера",
"URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com/c або https://example.com/stalker_portal/c).",
"ADD": "Дадаць",
"VALIDATING": "Праверка партала..."
"VALIDATING": "Праверка партала...",
"CREDENTIALS_HINT": "Патрэбна толькі тады, калі партал запытвае лагін і пароль",
"AUTH_FAILED": "Не ўдалося аўтэнтыфікавацца на партале. Праверце URL і MAC-адрас.",
"LOGIN_REQUIRED": "Гэты партал патрабуе лагін і пароль. Запоўніце палі імя карыстальніка і пароля і паспрабуйце яшчэ раз.",
"LOGIN_REJECTED": "Партал адхіліў лагін і пароль.",
"PORTAL_REFUSED": "Партал адмовіў у доступе для гэтай прылады.",
"PORTAL_MESSAGE": "Партал паведаміў: {{message}}"
},
"FILTER_BY_NAME": "Фільтраваць па імені",
"FILTER_AND_SORT": "Фільтр і сартаванне",
@@ -957,6 +963,7 @@
"TITLE": "Катэгорыя не выбрана",
"DESCRIPTION": "Калі ласка, выберыце катэгорыю, каб убачыць кантэнт"
},
"STALKER_LOGIN_REQUIRED": "Партал патрабуе лагін і пароль. Імпартуйце партал яшчэ раз і запоўніце палі імя карыстальніка і пароля.",
"PLAYLIST_SETTINGS": "Налады плэйліста",
"HOME": "На галоўную",
"DELETE": "Выдаліць"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Server-URL",
"URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)",
"ADD": "Hinzufügen",
"VALIDATING": "Portal wird überprüft …"
"VALIDATING": "Portal wird überprüft …",
"CREDENTIALS_HINT": "Nur erforderlich, wenn das Portal Benutzername und Passwort verlangt",
"AUTH_FAILED": "Authentifizierung am Portal fehlgeschlagen. Überprüfen Sie die URL und die MAC-Adresse.",
"LOGIN_REQUIRED": "Dieses Portal erfordert Benutzername und Passwort. Füllen Sie die Felder Benutzername und Passwort aus und versuchen Sie es erneut.",
"LOGIN_REJECTED": "Das Portal hat Benutzername und Passwort abgelehnt.",
"PORTAL_REFUSED": "Das Portal hat den Zugriff für dieses Gerät verweigert.",
"PORTAL_MESSAGE": "Das Portal meldet: {{message}}"
},
"FILTER_BY_NAME": "Nach Namen filtern",
"FILTER_AND_SORT": "Filtern & Sortieren",
@@ -957,6 +963,7 @@
"TITLE": "Keine Kategorie ausgewählt",
"DESCRIPTION": "Bitte wählen Sie eine Kategorie aus, um den Inhalt anzuzeigen"
},
"STALKER_LOGIN_REQUIRED": "Das Portal erfordert Benutzername und Passwort. Importieren Sie das Portal erneut und füllen Sie die Felder Benutzername und Passwort aus.",
"PLAYLIST_SETTINGS": "Playlist-Einstellungen",
"HOME": "Startseite",
"DELETE": "Löschen"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Διεύθηνση URL σέρβερ",
"URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com/c ή https://example.com/stalker_portal/c)",
"ADD": "Προσθήκη",
"VALIDATING": "Επικύρωση πύλης..."
"VALIDATING": "Επικύρωση πύλης...",
"CREDENTIALS_HINT": "Απαιτείται μόνο όταν η πύλη ζητά όνομα χρήστη και κωδικό πρόσβασης",
"AUTH_FAILED": "Η ταυτοποίηση με την πύλη απέτυχε. Ελέγξτε τη διεύθυνση URL και τη διεύθυνση MAC.",
"LOGIN_REQUIRED": "Αυτή η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης και δοκιμάστε ξανά.",
"LOGIN_REJECTED": "Η πύλη απέρριψε το όνομα χρήστη και τον κωδικό πρόσβασης.",
"PORTAL_REFUSED": "Η πύλη αρνήθηκε την πρόσβαση για αυτήν τη συσκευή.",
"PORTAL_MESSAGE": "Η πύλη ανέφερε: {{message}}"
},
"FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα",
"FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση",
@@ -957,6 +963,7 @@
"TITLE": "Δεν έχει επιλεγεί κατηγορία",
"DESCRIPTION": "Επιλέξτε μια κατηγορία για να δείτε το περιεχόμενο"
},
"STALKER_LOGIN_REQUIRED": "Η πύλη απαιτεί όνομα χρήστη και κωδικό πρόσβασης. Εισαγάγετε ξανά την πύλη και συμπληρώστε τα πεδία ονόματος χρήστη και κωδικού πρόσβασης.",
"PLAYLIST_SETTINGS": "Ρυθμίσεις λίστας αναπαραγωγής",
"HOME": "Αρχικό",
"DELETE": "Διαγραφή"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Server URL",
"URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com/c or https://example.com/stalker_portal/c)",
"ADD": "Add",
"VALIDATING": "Validating portal..."
"VALIDATING": "Validating portal...",
"CREDENTIALS_HINT": "Only needed when the portal asks for a login and password",
"AUTH_FAILED": "Failed to authenticate with the portal. Check the URL and MAC address.",
"LOGIN_REQUIRED": "This portal requires a login and password. Fill in the username and password fields and try again.",
"LOGIN_REJECTED": "The portal rejected the login and password.",
"PORTAL_REFUSED": "The portal refused access for this device.",
"PORTAL_MESSAGE": "The portal reported: {{message}}"
},
"FILTER_BY_NAME": "Filter by name",
"FILTER_AND_SORT": "Filter & Sort",
@@ -957,6 +963,7 @@
"TITLE": "No category selected",
"DESCRIPTION": "Please select a category to see the content"
},
"STALKER_LOGIN_REQUIRED": "The portal requires a login and password. Import the portal again and fill in the username and password fields.",
"PLAYLIST_SETTINGS": "Playlist Settings",
"HOME": "Home",
"DELETE": "Delete"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL del servidor",
"URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com/c o https://example.com/stalker_portal/c).",
"ADD": "Agregar",
"VALIDATING": "Validando portal…"
"VALIDATING": "Validando portal…",
"CREDENTIALS_HINT": "Solo es necesario cuando el portal pide usuario y contraseña",
"AUTH_FAILED": "No se pudo autenticar con el portal. Verifica la URL y la dirección MAC.",
"LOGIN_REQUIRED": "Este portal requiere usuario y contraseña. Completa los campos de nombre de usuario y contraseña e inténtalo de nuevo.",
"LOGIN_REJECTED": "El portal rechazó el usuario y la contraseña.",
"PORTAL_REFUSED": "El portal denegó el acceso a este dispositivo.",
"PORTAL_MESSAGE": "El portal informó: {{message}}"
},
"FILTER_BY_NAME": "Filtrar por nombre",
"FILTER_AND_SORT": "Filtrar y ordenar",
@@ -957,6 +963,7 @@
"TITLE": "Ninguna categoría seleccionada",
"DESCRIPTION": "Selecciona una categoría para ver el contenido"
},
"STALKER_LOGIN_REQUIRED": "El portal requiere usuario y contraseña. Vuelve a importar el portal y completa los campos de nombre de usuario y contraseña.",
"PLAYLIST_SETTINGS": "Configuración de lista de reproducción",
"HOME": "Hogar",
"DELETE": "Borrar"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL du serveur",
"URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com/c ou https://example.com/stalker_portal/c)",
"ADD": "Ajouter",
"VALIDATING": "Validation du portail…"
"VALIDATING": "Validation du portail…",
"CREDENTIALS_HINT": "Nécessaire uniquement lorsque le portail demande un identifiant et un mot de passe",
"AUTH_FAILED": "Échec de l'authentification auprès du portail. Vérifiez l'URL et l'adresse MAC.",
"LOGIN_REQUIRED": "Ce portail nécessite un identifiant et un mot de passe. Renseignez les champs nom d'utilisateur et mot de passe, puis réessayez.",
"LOGIN_REJECTED": "Le portail a rejeté l'identifiant et le mot de passe.",
"PORTAL_REFUSED": "Le portail a refusé l'accès pour cet appareil.",
"PORTAL_MESSAGE": "Le portail a signalé : {{message}}"
},
"FILTER_BY_NAME": "Filtrer par nom",
"FILTER_AND_SORT": "Filtrer et trier",
@@ -957,6 +963,7 @@
"TITLE": "Aucune catégorie sélectionnée",
"DESCRIPTION": "Veuillez sélectionner une catégorie pour voir le contenu"
},
"STALKER_LOGIN_REQUIRED": "Le portail nécessite un identifiant et un mot de passe. Importez à nouveau le portail et renseignez les champs nom d'utilisateur et mot de passe.",
"PLAYLIST_SETTINGS": "Paramètres de la liste",
"HOME": "Accueil",
"DELETE": "Supprimer"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Kiszolgáló URL-címe",
"URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com/c vagy https://example.com/stalker_portal/c)",
"ADD": "Hozzáadás",
"VALIDATING": "Portál ellenőrzése…"
"VALIDATING": "Portál ellenőrzése…",
"CREDENTIALS_HINT": "Csak akkor szükséges, ha a portál felhasználónevet és jelszót kér",
"AUTH_FAILED": "Nem sikerült a hitelesítés a portálon. Ellenőrizze az URL-címet és a MAC-címet.",
"LOGIN_REQUIRED": "Ez a portál felhasználónevet és jelszót igényel. Töltse ki a felhasználónév és a jelszó mezőt, majd próbálja újra.",
"LOGIN_REJECTED": "A portál elutasította a felhasználónevet és a jelszót.",
"PORTAL_REFUSED": "A portál megtagadta a hozzáférést ettől az eszköztől.",
"PORTAL_MESSAGE": "A portál a következőt jelezte: {{message}}"
},
"FILTER_BY_NAME": "Szűrés név alapján",
"FILTER_AND_SORT": "Szűrés és rendezés",
@@ -957,6 +963,7 @@
"TITLE": "Nincs kiválasztott kategória",
"DESCRIPTION": "A tartalom megjelenítéséhez válasszon egy kategóriát."
},
"STALKER_LOGIN_REQUIRED": "A portál felhasználónevet és jelszót igényel. Importálja újra a portált, és töltse ki a felhasználónév és a jelszó mezőt.",
"PLAYLIST_SETTINGS": "Lejátszási lista beállításai",
"HOME": "Kezdőlap",
"DELETE": "Törlés"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL server",
"URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it/c o https://esempio.it/stalker_portal/c)",
"ADD": "Aggiungi",
"VALIDATING": "Validazione del portale..."
"VALIDATING": "Validazione del portale...",
"CREDENTIALS_HINT": "Necessario solo se il portale richiede nome utente e password",
"AUTH_FAILED": "Impossibile autenticarsi con il portale. Controlla l'URL e l'indirizzo MAC.",
"LOGIN_REQUIRED": "Questo portale richiede nome utente e password. Compila i campi nome utente e password e riprova.",
"LOGIN_REJECTED": "Il portale ha rifiutato il nome utente e la password.",
"PORTAL_REFUSED": "Il portale ha negato l'accesso a questo dispositivo.",
"PORTAL_MESSAGE": "Il portale ha segnalato: {{message}}"
},
"FILTER_BY_NAME": "Filtra per nome",
"FILTER_AND_SORT": "Filtra e ordina",
@@ -957,6 +963,7 @@
"TITLE": "Nessuna categoria selezionata",
"DESCRIPTION": "Seleziona una categoria per vedere i contenuti"
},
"STALKER_LOGIN_REQUIRED": "Il portale richiede nome utente e password. Importa di nuovo il portale e compila i campi nome utente e password.",
"PLAYLIST_SETTINGS": "Impostazioni Playlist",
"HOME": "Home",
"DELETE": "Elimina"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "サーバーURL",
"URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com/c または https://example.com/stalker_portal/c)",
"ADD": "追加",
"VALIDATING": "ポータルを検証中..."
"VALIDATING": "ポータルを検証中...",
"CREDENTIALS_HINT": "ポータルがユーザー名とパスワードを要求する場合のみ必要",
"AUTH_FAILED": "ポータルでの認証に失敗しました。URLとMACアドレスを確認してください。",
"LOGIN_REQUIRED": "このポータルにはユーザー名とパスワードが必要です。ユーザー名とパスワードの欄を入力して、もう一度お試しください。",
"LOGIN_REJECTED": "ポータルがユーザー名とパスワードを拒否しました。",
"PORTAL_REFUSED": "ポータルがこのデバイスのアクセスを拒否しました。",
"PORTAL_MESSAGE": "ポータルからの報告:{{message}}"
},
"FILTER_BY_NAME": "名前でフィルター",
"FILTER_AND_SORT": "フィルターと並び替え",
@@ -957,6 +963,7 @@
"TITLE": "カテゴリーが選択されていません",
"DESCRIPTION": "コンテンツを表示するにはカテゴリーを選択してください"
},
"STALKER_LOGIN_REQUIRED": "ポータルにはユーザー名とパスワードが必要です。ポータルを再インポートして、ユーザー名とパスワードの欄を入力してください。",
"PLAYLIST_SETTINGS": "プレイリスト設定",
"HOME": "ホーム",
"DELETE": "削除"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "서버 URL",
"URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com/c 또는 https://example.com/stalker_portal/c).",
"ADD": "추가하다",
"VALIDATING": "포털을 검증하는 중..."
"VALIDATING": "포털을 검증하는 중...",
"CREDENTIALS_HINT": "포털이 사용자 이름과 비밀번호를 요구하는 경우에만 필요",
"AUTH_FAILED": "포털 인증에 실패했습니다. URL과 MAC 주소를 확인하세요.",
"LOGIN_REQUIRED": "이 포털에는 사용자 이름과 비밀번호가 필요합니다. 사용자 이름과 비밀번호 필드를 입력한 후 다시 시도하세요.",
"LOGIN_REJECTED": "포털이 사용자 이름과 비밀번호를 거부했습니다.",
"PORTAL_REFUSED": "포털이 이 기기의 접근을 거부했습니다.",
"PORTAL_MESSAGE": "포털에서 보고한 내용: {{message}}"
},
"FILTER_BY_NAME": "이름으로 필터",
"FILTER_AND_SORT": "필터 및 정렬",
@@ -957,6 +963,7 @@
"TITLE": "선택된 카테고리가 없습니다",
"DESCRIPTION": "콘텐츠를 보려면 카테고리를 선택하세요"
},
"STALKER_LOGIN_REQUIRED": "포털에 사용자 이름과 비밀번호가 필요합니다. 포털을 다시 가져온 후 사용자 이름과 비밀번호 필드를 입력하세요.",
"PLAYLIST_SETTINGS": "재생목록 설정",
"HOME": "홈",
"DELETE": "삭제"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Server URL",
"URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com/c o https://example.com/stalker_portal/c)",
"ADD": "Toevoegen",
"VALIDATING": "Portaal valideren..."
"VALIDATING": "Portaal valideren...",
"CREDENTIALS_HINT": "Alleen nodig als het portaal om een gebruikersnaam en wachtwoord vraagt",
"AUTH_FAILED": "Aanmelden bij het portaal is mislukt. Controleer de URL en het MAC-adres.",
"LOGIN_REQUIRED": "Dit portaal vereist een gebruikersnaam en wachtwoord. Vul de velden gebruikersnaam en wachtwoord in en probeer het opnieuw.",
"LOGIN_REJECTED": "Het portaal heeft de gebruikersnaam en het wachtwoord geweigerd.",
"PORTAL_REFUSED": "Het portaal heeft de toegang voor dit apparaat geweigerd.",
"PORTAL_MESSAGE": "Het portaal meldde: {{message}}"
},
"FILTER_BY_NAME": "Filter op naam",
"FILTER_AND_SORT": "Filteren & sorteren",
@@ -957,6 +963,7 @@
"TITLE": "Geen categorie geselecteerd",
"DESCRIPTION": "Selecteer een categorie om de inhoud te bekijken"
},
"STALKER_LOGIN_REQUIRED": "Het portaal vereist een gebruikersnaam en wachtwoord. Importeer het portaal opnieuw en vul de velden gebruikersnaam en wachtwoord in.",
"PLAYLIST_SETTINGS": "Afspeellijst instellingen",
"HOME": "Home",
"DELETE": "Verwijderen"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL serwera",
"URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com/c lub https://example.com/stalker_portal/c)",
"ADD": "Dodaj",
"VALIDATING": "Walidacja portalu..."
"VALIDATING": "Walidacja portalu...",
"CREDENTIALS_HINT": "Potrzebne tylko wtedy, gdy portal wymaga loginu i hasła",
"AUTH_FAILED": "Nie udało się uwierzytelnić w portalu. Sprawdź URL i adres MAC.",
"LOGIN_REQUIRED": "Ten portal wymaga loginu i hasła. Wypełnij pola nazwy użytkownika i hasła i spróbuj ponownie.",
"LOGIN_REJECTED": "Portal odrzucił login i hasło.",
"PORTAL_REFUSED": "Portal odmówił dostępu temu urządzeniu.",
"PORTAL_MESSAGE": "Portal zgłosił: {{message}}"
},
"FILTER_BY_NAME": "Filtruj według nazwy",
"FILTER_AND_SORT": "Filtruj i sortuj",
@@ -957,6 +963,7 @@
"TITLE": "Nie wybrano kategorii",
"DESCRIPTION": "Wybierz kategorię, aby zobaczyć treści"
},
"STALKER_LOGIN_REQUIRED": "Portal wymaga loginu i hasła. Zaimportuj portal ponownie i wypełnij pola nazwy użytkownika i hasła.",
"PLAYLIST_SETTINGS": "Ustawienia listy odtwarzania",
"HOME": "Strona główna",
"DELETE": "Usuń"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL do servidor",
"URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com/c ou https://example.com/stalker_portal/c)",
"ADD": "Adicionar",
"VALIDATING": "Validando portal..."
"VALIDATING": "Validando portal...",
"CREDENTIALS_HINT": "Necessário apenas quando o portal exige login e senha",
"AUTH_FAILED": "Falha na autenticação com o portal. Verifique a URL e o endereço MAC.",
"LOGIN_REQUIRED": "Este portal exige login e senha. Preencha os campos de nome de usuário e senha e tente novamente.",
"LOGIN_REJECTED": "O portal rejeitou o login e a senha.",
"PORTAL_REFUSED": "O portal recusou o acesso para este dispositivo.",
"PORTAL_MESSAGE": "O portal informou: {{message}}"
},
"FILTER_BY_NAME": "Filtrar por nome",
"FILTER_AND_SORT": "Filtrar e ordenar",
@@ -957,6 +963,7 @@
"TITLE": "Nenhuma categoria selecionada",
"DESCRIPTION": "Selecione uma categoria para ver o conteúdo"
},
"STALKER_LOGIN_REQUIRED": "O portal exige login e senha. Importe o portal novamente e preencha os campos de nome de usuário e senha.",
"PLAYLIST_SETTINGS": "Configurações da playlist",
"HOME": "Início",
"DELETE": "Excluir"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "URL сервера",
"URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c).",
"ADD": "Добавить",
"VALIDATING": "Проверка портала…"
"VALIDATING": "Проверка портала…",
"CREDENTIALS_HINT": "Требуется, только если портал запрашивает логин и пароль",
"AUTH_FAILED": "Не удалось авторизоваться на портале. Проверьте URL и MAC-адрес.",
"LOGIN_REQUIRED": "Этот портал требует логин и пароль. Заполните поля имени пользователя и пароля и повторите попытку.",
"LOGIN_REJECTED": "Портал отклонил логин и пароль.",
"PORTAL_REFUSED": "Портал отказал в доступе этому устройству.",
"PORTAL_MESSAGE": "Портал сообщил: {{message}}"
},
"FILTER_BY_NAME": "Фильтровать по имени",
"FILTER_AND_SORT": "Фильтр и сортировка",
@@ -957,6 +963,7 @@
"TITLE": "Категория не выбрана",
"DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого"
},
"STALKER_LOGIN_REQUIRED": "Портал требует логин и пароль. Импортируйте портал заново и заполните поля имени пользователя и пароля.",
"PLAYLIST_SETTINGS": "Настройки плейлиста",
"HOME": "На главную",
"DELETE": "Удалить"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "Sunucu URL'si",
"URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com/c veya https://example.com/stalker_portal/c)",
"ADD": "Ekle",
"VALIDATING": "Portal doğrulanıyor..."
"VALIDATING": "Portal doğrulanıyor...",
"CREDENTIALS_HINT": "Yalnızca portal kullanıcı adı ve parola istediğinde gereklidir",
"AUTH_FAILED": "Portalda kimlik doğrulaması başarısız oldu. URL'yi ve MAC adresini kontrol edin.",
"LOGIN_REQUIRED": "Bu portal kullanıcı adı ve parola gerektiriyor. Kullanıcı adı ve parola alanlarını doldurup tekrar deneyin.",
"LOGIN_REJECTED": "Portal, kullanıcı adı ve parolayı reddetti.",
"PORTAL_REFUSED": "Portal bu cihaz için erişimi reddetti.",
"PORTAL_MESSAGE": "Portal şunu bildirdi: {{message}}"
},
"FILTER_BY_NAME": "İsime göre filtrele",
"FILTER_AND_SORT": "Filtrele ve Sırala",
@@ -957,6 +963,7 @@
"TITLE": "Kategori seçilmedi",
"DESCRIPTION": "İçeriği görmek için lütfen bir kategori seçin"
},
"STALKER_LOGIN_REQUIRED": "Portal, kullanıcı adı ve parola gerektiriyor. Portalı yeniden içe aktarın ve kullanıcı adı ile parola alanlarını doldurun.",
"PLAYLIST_SETTINGS": "Oynatma Listesi Ayarları",
"HOME": "Ana Sayfa",
"DELETE": "Sil"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "服务器 URL",
"URL_VALIDATION_ERROR": "应该是带有协议的有效网址(例如 http://example.com/c 或 https://example.com/stalker_portal/c)",
"ADD": "添加",
"VALIDATING": "正在验证门户…"
"VALIDATING": "正在验证门户…",
"CREDENTIALS_HINT": "仅在门户要求登录名和密码时才需要填写",
"AUTH_FAILED": "门户身份验证失败。请检查 URL 和 MAC 地址。",
"LOGIN_REQUIRED": "此门户需要登录名和密码。请填写用户名和密码字段后重试。",
"LOGIN_REJECTED": "门户拒绝了该登录名和密码。",
"PORTAL_REFUSED": "门户拒绝了此设备的访问。",
"PORTAL_MESSAGE": "门户返回信息:{{message}}"
},
"FILTER_BY_NAME": "按名称筛选",
"FILTER_AND_SORT": "筛选与排序",
@@ -957,6 +963,7 @@
"TITLE": "未选择分类",
"DESCRIPTION": "请选择一个分类以查看内容"
},
"STALKER_LOGIN_REQUIRED": "门户需要登录名和密码。请重新导入该门户并填写用户名和密码字段。",
"PLAYLIST_SETTINGS": "播放列表设置",
"HOME": "首页",
"DELETE": "删除"
+8 -1
View File
@@ -195,7 +195,13 @@
"SERVER_URL": "伺服器網址",
"URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com/c 或 https://example.com/stalker_portal/c)",
"ADD": "新增",
"VALIDATING": "正在驗證入口網站..."
"VALIDATING": "正在驗證入口網站...",
"CREDENTIALS_HINT": "僅在入口網站要求登入名稱與密碼時才需填寫",
"AUTH_FAILED": "入口網站驗證失敗。請檢查網址與 MAC 位址。",
"LOGIN_REQUIRED": "此入口網站需要登入名稱與密碼。請填寫使用者名稱與密碼欄位後重試。",
"LOGIN_REJECTED": "入口網站拒絕了此登入名稱與密碼。",
"PORTAL_REFUSED": "入口網站拒絕了此裝置的存取。",
"PORTAL_MESSAGE": "入口網站回報:{{message}}"
},
"FILTER_BY_NAME": "依名稱篩選",
"FILTER_AND_SORT": "篩選與排序",
@@ -957,6 +963,7 @@
"TITLE": "未選擇類別",
"DESCRIPTION": "請選擇一個類別以查看內容"
},
"STALKER_LOGIN_REQUIRED": "入口網站需要登入名稱與密碼。請重新匯入入口網站並填寫使用者名稱與密碼欄位。",
"PLAYLIST_SETTINGS": "播放清單設定",
"HOME": "首頁",
"DELETE": "刪除"
+112
View File
@@ -235,6 +235,118 @@ blank fields are not generated or forwarded to `get_profile`.
metadata is independent from M3U playlist EPG metadata and must not depend on
M3U-specific EPG fields.
## Session Authentication Lifecycle
Full portals authenticate through `StalkerSessionService`
(`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), which
is a facade over three focused modules:
- `stalker-auth.api.ts` — the raw `handshake` / `get_profile` / `do_auth`
requests and the `authenticate()` orchestration.
- `stalker-watchdog.controller.ts` — the periodic `get_events` keep-alive.
- `stalker-token-cache.ts` — the in-run token and pending-auth state, tagged
with the identity fingerprint each session was negotiated for.
- `stalker-session-store.ts` — the session persisted on the playlist row.
- `stalker-response-classification.ts` + `stalker-portal-error.ts` — failure
detection and the typed `StalkerPortalError` the UI layers render.
### Handshake and token persistence
The handshake token is **idempotent** (Stalker 4.9.35 `stb.class.php`):
re-presenting the MAC's current session token returns it unchanged, and tokens
have no TTL — they are only invalidated when another device runs `get_profile`
on the same MAC. `ensureToken()` exploits this: when the in-memory cache is
cold it re-presents the persisted `Playlist.stalkerToken` (from the playlist
object, falling back to the stored row via `PlaylistsService`, since store
metas do not carry payload fields). A token that comes back unchanged is an
already-adopted session, so the `get_profile` round trip is skipped entirely —
unless the handshake also set `not_valid`, which vetoes the shortcut: adopting
a token the portal just called dead would be unrecoverable, since the reuse
path writes no replacement back and every later start would re-present it.
A renegotiated session is written back best-effort
(`PlaylistsService.updateStalkerSession`) so the next app start can reuse it.
The handshake's `not_valid` flag is propagated into the follow-up
`get_profile` as `not_valid_token`.
Reuse is gated on identity. The fingerprint the session was negotiated for is
persisted next to the token (`Playlist.stalkerSessionIdentity`), and a token
whose fingerprint no longer matches the playlist is never re-presented — an
edited MAC, serial or device id must not inherit the previous session, which
is the same rule the in-memory cache enforces for the current run.
Because that reuse skips the only response carrying the watchdog cadence, the
cadence is persisted **with** the token (`Playlist.stalkerWatchdogTimeout` /
`stalkerTimeslot`, payload fields like `stalkerToken` itself — no schema
change) and re-applied on the reuse path. The import dialog persists what its
own `get_profile` advertised, which for a portal whose token never goes stale
is the only profile the app ever sees.
The skip is therefore conditional on the cadence being **known**: a playlist
imported before the cadence was persisted has a reusable token and no
cadence, and skipping would strand it on the 120 s default permanently, since
the profile is the only thing that could teach it. Such a playlist runs one
profile, persists what it learns, and skips from then on. What gets persisted
is the *effective* cadence (the 120 s default when the portal advertises
none), so stored absence keeps meaning exactly one thing — never profiled —
rather than sending a portal that advertises nothing back through a profile on
every start.
### `get_profile` status decoding
`authenticate()` decodes `js.status` the way the stock middleware means it:
- full profile / `status: 0` — OK; `watchdog_timeout` and `timeslot` are read
for the watchdog cadence.
- `status: 1` — blocked (device conflict, malformed MAC, disabled account).
`msg`/`block_msg` carry the portal's own explanation; they are
markup-stripped, combined, and thrown as `StalkerPortalError('blocked')`.
- `status: 2` — login/password required. The client runs `do_auth`
(`login`, `password`, plus `device_id`/`device_id2` when configured) and
retries `get_profile` with `auth_second_step=1`. Only that retry claims the
second auth step — the initial request sends `auth_second_step=0`. Missing
credentials throw `StalkerPortalError('login-required')`; a `{js: false}`
verdict (the operator billing script refused) throws `'login-rejected'`.
Credentials come from the import dialog's username/password fields and are
persisted on the playlist, so runtime re-authentication can repeat `do_auth`
after the portal drops the session.
### Plain-text failure bodies
Auth failures are **HTTP 200 + a text/html body**, never a 401/403. The three
exact bodies (`Authorization failed.` — stale/missing token, optionally with a
numeric debug suffix; `Access denied.` — blocked account;
`Unauthorized request.` — missing mac cookie) are classified at the transport
boundary: the Electron main process
(`apps/electron-backend/src/app/events/stalker.events.ts`) converts them into
a structured `{ stalkerAuthFailure }` marker
(`libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`) — returned,
not thrown, because `ipcRenderer.invoke` strips custom properties from
rejections. The PWA proxy path still delivers the raw string; the renderer
classifier accepts both shapes plus the legacy `{ js: '<body>' }` envelope.
`makeAuthenticatedRequest` retries once with fresh authentication and
otherwise throws `StalkerPortalError('auth-failed')` carrying the body.
### Error surfacing
`StalkerPortalError.portalText` holds the portal's own words. The import
dialog shows them in its failure snackbar (with kind-specific i18n headlines,
`HOME.STALKER_PORTAL.*`); the workspace context panel replaces the generic
"could not load categories" hint with the portal text (or the login-required
guidance) when category loading failed with a portal refusal
(`stalkerCategoryErrorDescription` in `workspace-context-panel.component.ts`).
### Watchdog
The portal expects `get_events` every `watchdog_timeout` seconds — **120 by
default**, echoed in the profile together with a per-user `timeslot` jitter
that offsets the first periodic ping. `StalkerWatchdogController` starts with
an immediate `init=1` ping on activation, applies the profile cadence when a
profile is decoded (clamped to 30–3600 s against garbage), and otherwise uses
the documented 120 s default. Failing to ping never invalidates the session —
it only affects the portal's admin-panel "online" reporting — so ping failures
are logged and never retried or escalated.
## Request Transport and `cmd` Encoding
A real MAG/STB sends `cmd` unencoded: the portal's client JS concatenates raw
@@ -101,16 +101,27 @@
'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate
}}</mat-hint>
</mat-form-field>
<!-- <mat-form-field class="w-full">
<mat-form-field class="w-full">
<mat-label for="username">{{
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
}}</mat-label>
<input matInput type="text" id="username" formControlName="username" />
<mat-hint>{{
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
}}</mat-hint>
</mat-form-field>
<mat-form-field class="w-full">
<mat-label for="password">{{
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
</mat-form-field> -->
<input
matInput
type="password"
id="password"
formControlName="password"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
}}</mat-hint>
</mat-form-field>
</form>
@@ -2,13 +2,17 @@ import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access';
import {
StalkerPortalDiscoveryService,
StalkerPortalError,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerPortalImportComponent } from './stalker-portal-import.component';
describe('StalkerPortalImportComponent identity handling', () => {
let component: StalkerPortalImportComponent;
let portalDiscovery: { discover: jest.Mock };
let store: { dispatch: jest.Mock };
let snackBar: { open: jest.Mock };
beforeEach(() => {
portalDiscovery = {
@@ -23,6 +27,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
store = {
dispatch: jest.fn(),
};
snackBar = { open: jest.fn() };
TestBed.configureTestingModule({
providers: [
@@ -33,7 +38,7 @@ describe('StalkerPortalImportComponent identity handling', () => {
{ provide: Store, useValue: store },
{
provide: MatSnackBar,
useValue: { open: jest.fn() },
useValue: snackBar,
},
{
provide: TranslateService,
@@ -72,7 +77,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
}
},
// Credentials ride along for portals that answer get_profile
// with status 2 (login/password required).
{ credentials: { username: '', password: '' } }
);
const playlist = store.dispatch.mock.calls[0][0].playlist;
@@ -115,7 +123,8 @@ describe('StalkerPortalImportComponent identity handling', () => {
expect(portalDiscovery.discover).toHaveBeenCalledWith(
'https://portal.example.com/stalker_portal/c',
'00:1A:79:AA:BB:CC',
{}
{},
{ credentials: { username: '', password: '' } }
);
const playlist = store.dispatch.mock.calls[0][0].playlist;
@@ -131,6 +140,135 @@ describe('StalkerPortalImportComponent identity handling', () => {
expect(playlist.signature2).toBeUndefined();
});
it('passes the entered login and password into discovery', async () => {
component.form.patchValue({
_id: 'playlist-login',
title: 'Login Portal',
macAddress: '00:1A:79:00:00:08',
portalUrl: 'https://portal.example.com/stalker_portal/c',
username: 'user',
password: 'secret',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
expect(portalDiscovery.discover).toHaveBeenCalledWith(
expect.any(String),
'00:1A:79:00:00:08',
{},
{ credentials: { username: 'user', password: 'secret' } }
);
// Persisted so runtime re-auth can repeat do_auth after the portal
// drops the session.
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist.username).toBe('user');
expect(playlist.password).toBe('secret');
});
it('persists the cadence and the identity the token was negotiated for', async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
watchdogTimeoutSeconds: 90,
timeslotSeconds: 11,
});
component.form.patchValue({
_id: 'playlist-cadence',
title: 'Cadence Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
const playlist = store.dispatch.mock.calls[0][0].playlist;
expect(playlist.stalkerWatchdogTimeout).toBe(90);
expect(playlist.stalkerTimeslot).toBe(11);
// Without this a later start would re-present the token under an
// edited identity.
expect(playlist.stalkerSessionIdentity).toEqual(expect.any(String));
});
it('records the effective cadence when the portal advertises none', async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
isFullStalkerPortal: true,
token: 'token-1',
});
component.form.patchValue({
_id: 'playlist-default-cadence',
title: 'Quiet Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
const playlist = store.dispatch.mock.calls[0][0].playlist;
// Stored absence has to keep meaning "never profiled", or every
// later start would re-profile such a portal.
expect(playlist.stalkerWatchdogTimeout).toBe(120);
expect(playlist.stalkerTimeslot).toBe(0);
});
it("relays the portal's own refusal instead of a generic error", async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError('login-required'),
});
component.form.patchValue({
_id: 'playlist-refused',
title: 'Login Portal',
macAddress: '00:1A:79:00:00:08',
portalUrl: 'https://portal.example.com/stalker_portal/c',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
undefined,
expect.any(Object)
);
expect(store.dispatch).not.toHaveBeenCalled();
});
it("appends the portal's explanation to a blocked refusal", async () => {
portalDiscovery.discover.mockResolvedValue({
status: 'auth-rejected',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
error: new StalkerPortalError(
'blocked',
'device conflict - device_id mismatch'
),
});
component.form.patchValue({
_id: 'playlist-blocked',
title: 'Blocked Portal',
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://portal.example.com/stalker_portal/c',
importDate: '2026-05-15T00:00:00.000Z',
});
await component.addPlaylist();
// The translate mock returns keys, so both the kind headline and the
// portal-message wrapper must be present.
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.STALKER_PORTAL.PORTAL_REFUSED HOME.STALKER_PORTAL.PORTAL_MESSAGE',
undefined,
expect.any(Object)
);
});
it('classifies the offline fallback on the normalized URL, not the raw query', async () => {
// A query merely MENTIONING /server/load.php must not make a
// panel-style /c URL look canonical and abort the offline import.
@@ -13,16 +13,21 @@ import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
asStalkerPortalError,
legacyTransformStalkerPortalUrl,
normalizeStalkerPortalInputUrl,
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
StalkerPortalDiscoveryService,
StalkerPortalIdentity,
normalizeStalkerPortalIdentity,
stalkerIdentityFingerprint,
type StalkerPortalErrorKind,
} from '@iptvnator/portal/stalker/data-access';
import {
createRandomId,
isFullStalkerPortalUrl,
Playlist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
@Component({
@@ -129,18 +134,37 @@ export class StalkerPortalImportComponent {
const discovery = await this.portalDiscovery.discover(
originalUrl,
formValue.macAddress ?? '',
stalkerIdentity
stalkerIdentity,
{
credentials: {
username: formValue.username ?? '',
password: formValue.password ?? '',
},
}
);
let portalUrl: string;
let isFullStalkerPortal: boolean;
let stalkerToken: string | undefined;
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
// The import profile is the only get_profile some portals ever
// see: later starts reuse the token and skip it, so the cadence
// it advertises has to be persisted here or the watchdog would
// stay on the 120 s default forever. Effective values, so stored
// absence keeps meaning "never profiled".
let stalkerWatchdogTimeout: number | undefined;
let stalkerTimeslot: number | undefined;
if (discovery.status === 'resolved') {
portalUrl = discovery.portalUrl;
isFullStalkerPortal = discovery.isFullStalkerPortal;
stalkerToken = discovery.token;
if (stalkerToken) {
stalkerWatchdogTimeout =
discovery.watchdogTimeoutSeconds ??
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS;
stalkerTimeslot = discovery.timeslotSeconds ?? 0;
}
if (discovery.accountInfo) {
stalkerAccountInfo = {
@@ -167,10 +191,13 @@ export class StalkerPortalImportComponent {
'[StalkerImport] Authentication failed:',
discovery.error
);
// The portal explains its own refusals — a demanded login, a
// rejected one, a device conflict — so relay those words
// instead of the generic "check URL and MAC".
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
this.buildAuthErrorMessage(discovery.error),
undefined,
{ duration: 5000 }
{ duration: 8000 }
);
return;
} else if (
@@ -219,6 +246,19 @@ export class StalkerPortalImportComponent {
portalUrl,
isFullStalkerPortal,
stalkerToken,
// The identity this token was negotiated for. Reuse is
// refused when it no longer matches, so an edited MAC cannot
// inherit the previous session.
...(stalkerToken
? {
stalkerSessionIdentity: stalkerIdentityFingerprint({
macAddress: formValue.macAddress ?? '',
...this.toPlaylistIdentityFields(stalkerIdentity),
} as PlaylistMeta),
}
: {}),
stalkerWatchdogTimeout,
stalkerTimeslot,
stalkerAccountInfo,
...this.toPlaylistIdentityFields(stalkerIdentity),
} as Playlist;
@@ -230,6 +270,36 @@ export class StalkerPortalImportComponent {
}
}
/**
* Turns an authentication failure into a message the user can act on.
* The portal explains refusals itself (`msg`/`block_msg`, or one of the
* documented plain-text bodies); its own words are appended verbatim.
*/
private buildAuthErrorMessage(error: unknown): string {
const portalError = asStalkerPortalError(error);
const keyByKind: Record<StalkerPortalErrorKind, string> = {
'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED',
blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
};
const base = this.translate.instant(
portalError
? keyByKind[portalError.kind]
: 'HOME.STALKER_PORTAL.AUTH_FAILED'
);
if (portalError?.portalText) {
const detail = this.translate.instant(
'HOME.STALKER_PORTAL.PORTAL_MESSAGE',
{ message: portalError.portalText }
);
return `${base} ${detail}`;
}
return base;
}
private toPlaylistIdentityFields(identity: StalkerPortalIdentity): {
stalkerSerialNumber?: string;
stalkerDeviceId1?: string;
@@ -25,6 +25,7 @@ describe('StreamResolverService', () => {
let dataService: { sendIpcEvent: jest.Mock };
let stalkerSession: {
getCachedToken: jest.Mock;
ensureToken: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
};
let epgBridge: Partial<EpgRuntimeBridgeService>;
@@ -44,6 +45,7 @@ describe('StreamResolverService', () => {
};
stalkerSession = {
getCachedToken: jest.fn(() => null),
ensureToken: jest.fn().mockResolvedValue({ token: null }),
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({ token: null }),
};
@@ -818,6 +820,105 @@ describe('StreamResolverService', () => {
expect(playback.origin).toBe('https://stalker.example.com');
});
it('authenticates a cold session for a direct-URL radio favorite', async () => {
// A direct-URL radio favorite skips create_link entirely, so on a
// cold session nothing has authenticated and the in-memory cache is
// empty — the Bearer-gated stream would 403 in the audio player.
// Re-presenting the persisted token costs one idempotent handshake.
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl:
'https://stalker.example.com/stalker_portal/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: true,
stalkerToken: 'PERSISTED77',
} satisfies Partial<Playlist>)
);
stalkerSession.getCachedToken.mockReturnValue(null);
stalkerSession.ensureToken.mockResolvedValue({
token: 'PERSISTED77',
});
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::99',
name: 'Gated Radio',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '99',
radio: 'true',
stalkerCmd: 'https://stalker.example.com/radio/99.mp3',
} satisfies UnifiedCollectionItem);
expect(stalkerSession.ensureToken).toHaveBeenCalled();
// The fast path must stay a fast path: no create_link round trip.
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
expect(playback.headers?.['Authorization']).toBe('Bearer PERSISTED77');
});
it('still plays when cold-session authentication fails', async () => {
// Many portals do not gate the stream itself — a failed handshake
// must not block playback, only omit the Bearer header.
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl:
'https://stalker.example.com/stalker_portal/server/load.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: true,
} satisfies Partial<Playlist>)
);
stalkerSession.getCachedToken.mockReturnValue(null);
stalkerSession.ensureToken.mockRejectedValue(
new Error('handshake refused')
);
const playback = await service.resolvePlayback({
uid: 'stalker::stalker-1::99',
name: 'Gated Radio',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '99',
radio: 'true',
stalkerCmd: 'https://stalker.example.com/radio/99.mp3',
} satisfies UnifiedCollectionItem);
expect(playback.streamUrl).toBe(
'https://stalker.example.com/radio/99.mp3'
);
expect(playback.headers?.['Authorization']).toBeUndefined();
});
it('does not authenticate a simple portal for playback headers', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-2',
portalUrl: 'https://simple.example.com/portal.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
);
stalkerSession.getCachedToken.mockReturnValue(null);
await service.resolvePlayback({
uid: 'stalker::stalker-2::99',
name: 'Simple Radio',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-2',
playlistName: 'Stalker',
stalkerId: '99',
radio: 'true',
stalkerCmd: 'https://simple.example.com/radio/99.mp3',
} satisfies UnifiedCollectionItem);
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
});
it('keeps Stalker collection playback from a foreign CDN credential-free', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -505,7 +505,7 @@ export class StreamResolverService {
* them to the stream origin; foreign hosts get the credential-free
* profile from the shared classifier).
*/
private buildStalkerPlayback(
private async buildStalkerPlayback(
item: UnifiedCollectionItem,
playlist: Playlist | undefined,
resolved: {
@@ -514,7 +514,7 @@ export class StreamResolverService {
streamUrl: string;
isLive?: boolean;
}
): ResolvedPortalPlayback {
): Promise<ResolvedPortalPlayback> {
// The item may carry portal/mac overrides for playlists that no
// longer exist; the builder only reads header-relevant fields.
const headerPlaylist = {
@@ -522,7 +522,10 @@ export class StreamResolverService {
macAddress: resolved.macAddress,
portalUrl: resolved.portalUrl,
} as Playlist;
const token = this.stalkerSession.getCachedToken(item.playlistId);
const token = await this.resolveStalkerPlaybackToken(
item.playlistId,
playlist
);
const headers = buildStalkerExternalPlaybackHeaders(
headerPlaylist,
token,
@@ -550,6 +553,33 @@ export class StreamResolverService {
};
}
/**
* Resolves the Bearer token a full portal's stream needs.
*
* A direct-URL radio favorite skips `create_link` entirely, so on a cold
* session nothing has authenticated yet and the in-memory cache is empty —
* a same-host Bearer-gated stream would then 403 in the built-in audio
* player. `ensureToken()` re-presents the persisted token instead, which
* is a single idempotent handshake rather than a full re-auth. Failures
* stay non-fatal: many portals do not gate the stream itself.
*/
private async resolveStalkerPlaybackToken(
playlistId: string,
playlist: Playlist | undefined
): Promise<string | null> {
const cached = this.stalkerSession.getCachedToken(playlistId);
if (cached || !playlist?.isFullStalkerPortal) {
return cached;
}
try {
const { token } = await this.stalkerSession.ensureToken(playlist);
return token;
} catch {
return null;
}
}
private buildStalkerRadioChannel(
item: UnifiedCollectionItem,
playback: ResolvedPortalPlayback
@@ -2,6 +2,9 @@ export * from './lib/models';
export * from './lib/stores';
export * from './lib/stalker-account-info.service';
export * from './lib/stalker-content-types';
export * from './lib/stalker-portal-error';
export * from './lib/stalker-response-classification';
export * from './lib/stalker-watchdog.controller';
export * from './lib/stalker-itv-cache.service';
export * from './lib/stalker-live-playback.utils';
export * from './lib/stalker-portal-discovery.service';
@@ -0,0 +1,303 @@
import type { DataService } from '@iptvnator/services';
import type { createLogger } from '@iptvnator/portal/shared/util';
import { StalkerAuthApi } from './stalker-auth.api';
/**
* Client-side mirror of the mock server's `auth-handlers.spec.ts`: the same
* documented flows — status 2 → do_auth → profile retry, blocked profiles,
* idempotent token reuse, not_valid propagation — asserted against the
* requests the client actually sends.
*/
describe('StalkerAuthApi', () => {
const portalUrl =
'https://portal.example.com/stalker_portal/server/load.php';
const macAddress = '00:1A:79:AA:BB:CC';
let sendIpcEvent: jest.Mock;
let api: StalkerAuthApi;
const logger = {
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
} as unknown as ReturnType<typeof createLogger>;
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: {
subtle: {
digest: jest.fn(
async () => new Uint8Array(20).fill(1).buffer
),
},
},
});
sendIpcEvent = jest.fn();
api = new StalkerAuthApi(
{ sendIpcEvent } as unknown as DataService,
logger
);
});
function callsByAction(action: string) {
return sendIpcEvent.mock.calls.filter(
(call) => call[1].params.action === action
);
}
it('walks the login-required flow: status 2 -> do_auth -> profile retry', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1', not_valid: 0 },
})
.mockResolvedValueOnce({
js: { status: 2, template: 'auth', info: 'Login required' },
})
.mockResolvedValueOnce({ js: true })
.mockResolvedValueOnce({
js: { status: 0, watchdog_timeout: 120, timeslot: 15 },
});
const result = await api.authenticate(
portalUrl,
macAddress,
{ deviceId1: 'DEV-1', deviceId2: 'DEV-2' },
{ credentials: { username: 'user', password: 'secret' } }
);
expect(result.token).toBe('TOKEN-1');
expect(result.watchdogTimeoutSeconds).toBe(120);
expect(result.timeslotSeconds).toBe(15);
// First profile request is NOT the second auth step.
const profiles = callsByAction('get_profile');
expect(profiles).toHaveLength(2);
expect(profiles[0][1].params.auth_second_step).toBe('0');
// The retry after do_auth is.
expect(profiles[1][1].params.auth_second_step).toBe('1');
// do_auth carries the credentials and the device identity.
const doAuth = callsByAction('do_auth');
expect(doAuth).toHaveLength(1);
expect(doAuth[0][1].params).toEqual(
expect.objectContaining({
login: 'user',
password: 'secret',
device_id: 'DEV-1',
device_id2: 'DEV-2',
})
);
});
it('throws login-required when the portal wants credentials and none are stored', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: 2 } });
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
name: 'StalkerPortalError',
kind: 'login-required',
});
// Empty credentials are never sent to the billing script.
expect(callsByAction('do_auth')).toHaveLength(0);
});
it('throws login-rejected when do_auth answers {js:false}', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: 2 } })
.mockResolvedValueOnce({ js: false });
await expect(
api.authenticate(portalUrl, macAddress, {}, {
credentials: { username: 'user', password: 'wrong' },
})
).rejects.toMatchObject({ kind: 'login-rejected' });
});
it('throws login-rejected when the profile still demands a login after do_auth', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: 2 } })
.mockResolvedValueOnce({ js: true })
.mockResolvedValueOnce({ js: { status: 2 } });
await expect(
api.authenticate(portalUrl, macAddress, {}, {
credentials: { username: 'user', password: 'secret' },
})
).rejects.toMatchObject({ kind: 'login-rejected' });
});
it('decodes a blocked profile into the portal explanation', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({
js: {
status: 1,
msg: 'device conflict - device_id mismatch',
block_msg: 'Your STB is damaged.<br/> Call the provider.',
},
});
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
kind: 'blocked',
portalText:
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
});
});
it('treats a bare {status:1} profile as refused', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: 1 } });
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({ kind: 'blocked' });
});
it('propagates the handshake not_valid flag into not_valid_token', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'NEW-TOKEN', random: 'r1', not_valid: 1 },
})
.mockResolvedValueOnce({ js: { status: 0 } });
await api.authenticate(portalUrl, macAddress, {}, {
storedToken: 'STALE-TOKEN',
});
const profile = callsByAction('get_profile')[0][1];
expect(profile.params.not_valid_token).toBe('1');
});
it('re-presents a stored token and skips get_profile when it comes back unchanged', async () => {
sendIpcEvent.mockResolvedValueOnce({
js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 0 },
});
const result = await api.authenticate(portalUrl, macAddress, {}, {
storedToken: 'STORED-TOKEN',
skipProfileWhenReused: true,
});
expect(result).toEqual({
token: 'STORED-TOKEN',
reusedStoredToken: true,
});
expect(callsByAction('handshake')[0][1].params.token).toBe(
'STORED-TOKEN'
);
expect(callsByAction('get_profile')).toHaveLength(0);
});
it('runs the full profile when the portal replaces a stale stored token', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'FRESH-TOKEN', random: 'r1', not_valid: 1 },
})
.mockResolvedValueOnce({ js: { status: 0 } });
const result = await api.authenticate(portalUrl, macAddress, {}, {
storedToken: 'STALE-TOKEN',
skipProfileWhenReused: true,
});
expect(result.token).toBe('FRESH-TOKEN');
expect(result.reusedStoredToken).toBe(false);
expect(callsByAction('get_profile')).toHaveLength(1);
});
it('does not reuse an echoed token the portal flagged not_valid', async () => {
// `not_valid: 1` is the portal saying the presented token is not a
// live session. Adopting it because the string matched would be
// unrecoverable: nothing writes a replacement back, so every later
// start would re-present the same dead token.
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'STORED-TOKEN', random: 'r1', not_valid: 1 },
})
.mockResolvedValueOnce({ js: { status: 0 } });
const result = await api.authenticate(portalUrl, macAddress, {}, {
storedToken: 'STORED-TOKEN',
skipProfileWhenReused: true,
});
expect(result.reusedStoredToken).toBe(false);
expect(callsByAction('get_profile')).toHaveLength(1);
expect(callsByAction('get_profile')[0][1].params.not_valid_token).toBe(
'1'
);
});
it('decodes a stringified status 2 into the login flow', async () => {
// Portals routinely stringify numeric fields — the same reason
// watchdog_timeout/timeslot accept strings. A strict === would read
// "2" as a healthy profile and skip do_auth entirely.
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: '2' } })
.mockResolvedValueOnce({ js: true })
.mockResolvedValueOnce({ js: { status: '0' } });
const result = await api.authenticate(portalUrl, macAddress, {}, {
credentials: { username: 'user', password: 'secret' },
});
expect(result.token).toBe('TOKEN-1');
expect(callsByAction('do_auth')).toHaveLength(1);
expect(
callsByAction('get_profile')[1][1].params.auth_second_step
).toBe('1');
});
it('decodes a stringified status 1 as a refusal', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({ js: { status: '1' } });
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({ kind: 'blocked' });
});
it('classifies a transport auth-failure marker during get_profile', async () => {
sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'TOKEN-1', random: 'r1' },
})
.mockResolvedValueOnce({
stalkerAuthFailure: 'Unauthorized request.',
});
await expect(
api.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
kind: 'auth-failed',
failureBody: 'Unauthorized request.',
});
});
});
@@ -0,0 +1,477 @@
import type { DataService } from '@iptvnator/services';
import {
extractStalkerAuthFailureBody,
STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import type { createLogger } from '@iptvnator/portal/shared/util';
import {
normalizeStalkerPortalIdentity,
type StalkerPortalIdentity,
} from './stalker-identity.utils';
import {
combineStalkerPortalMessages,
StalkerPortalError,
} from './stalker-portal-error';
export interface StalkerHandshakeResponse {
js: {
token: string;
not_valid?: number;
random?: string;
};
}
export interface StalkerProfileResponse {
js: {
id?: string;
name?: string;
mac?: string;
status?: number;
msg?: string;
block_msg?: string;
/** Watchdog cadence in seconds, echoed by the portal (default 120). */
watchdog_timeout?: number | string;
/** Per-user jitter in seconds so watchdog pings do not align. */
timeslot?: number | string;
account_info?: {
login?: string;
expire_date?: number;
tariff_plan_name?: string;
status?: number;
};
};
}
interface StalkerAuthConfirmationResponse {
js?: boolean;
}
export interface StalkerPortalCredentials {
username?: string;
password?: string;
}
export interface StalkerAuthenticateOptions {
/**
* Previously persisted session token. The handshake is idempotent: it is
* re-presented, and the portal returns it unchanged while it is still the
* MAC's session token.
*/
storedToken?: string;
/** Login/password for portals that answer `get_profile` with status 2. */
credentials?: StalkerPortalCredentials;
/**
* Skip the `get_profile` round trip when the stored token came back
* unchanged — an unchanged token is already an adopted session.
*/
skipProfileWhenReused?: boolean;
}
export interface StalkerAuthenticationResult {
token: string;
accountInfo?: StalkerProfileResponse['js']['account_info'];
/**
* Raw envelope so callers can apply their own failure checks — endpoint
* discovery rejects a candidate whose `get_profile` refuses even though
* the handshake succeeded. Absent when the stored token was reused and
* no profile was fetched.
*/
profileResponse?: StalkerProfileResponse;
/** True when the stored token was accepted and `get_profile` was skipped. */
reusedStoredToken?: boolean;
watchdogTimeoutSeconds?: number;
timeslotSeconds?: number;
}
interface StalkerHandshakeOutcome {
token: string;
random: string;
/** The portal flagged the presented stored token as no longer valid. */
notValid: boolean;
}
/**
* SHA1 hash using native Web Crypto API
* Produces correct 40-character hex hash matching real Stalker clients
*/
async function sha1(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* Generates SHA1 prehash from MAC address
* This must match what real Stalker clients send
*/
async function generatePrehash(macAddress: string): Promise<string> {
// Use MAC address with colons, uppercase - this is what most clients use
const str = macAddress.toUpperCase();
return (await sha1(str)).toUpperCase();
}
/**
* Generates a random string for metrics
*/
function generateRandom(): string {
const chars = 'abcdef0123456789';
let result = '';
for (let i = 0; i < 40; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function toFiniteNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
/**
* Throws when a response is one of the portal's plain-text auth-failure
* bodies (already classified by the transport, or still raw on legacy paths).
*/
function assertNotAuthFailure(response: unknown): void {
const failureBody = extractStalkerAuthFailureBody(response);
if (failureBody) {
throw new StalkerPortalError('auth-failed', failureBody, failureBody);
}
}
/**
* Raw Stalker authentication requests plus the documented login flow.
*
* Protocol (Stalker 4.9.35): `handshake` issues an idempotent token;
* `get_profile` turns it into a session and decodes as: full profile = OK,
* `status: 1` = blocked (see `msg`/`block_msg`), `status: 2` = login/password
* required → `do_auth`, then `get_profile` again with `auth_second_step=1`.
*/
export class StalkerAuthApi {
constructor(
private readonly dataService: DataService,
private readonly logger: ReturnType<typeof createLogger>
) {}
async performHandshake(
portalUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {},
storedToken?: string
): Promise<StalkerHandshakeOutcome> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
const prehash = await generatePrehash(macAddress);
const params: Record<string, string> = {
type: 'stb',
action: 'handshake',
// Re-presenting a persisted token is how a real client resumes a
// session: an idempotent portal returns it unchanged.
token: storedToken ?? '',
prehash,
JsHttpRequest: '1-xml',
};
try {
const response =
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
assertNotAuthFailure(response);
if (response?.js?.token) {
return {
token: response.js.token,
random: response.js.random || generateRandom(),
notValid: Number(response.js.not_valid ?? 0) === 1,
};
}
this.logger.error('No token in response');
throw new Error('Handshake failed: No token received');
} catch (error) {
this.logger.error('Handshake error:', error);
throw error;
}
}
/**
* Gets account profile information to validate the portal and check
* subscription. `authSecondStep` is set only on the retry after `do_auth`;
* `notValidToken` propagates the handshake's `not_valid` flag.
*/
async getProfile(
portalUrl: string,
macAddress: string,
token: string,
identity: StalkerPortalIdentity,
handshakeRandom: string,
options: { authSecondStep?: boolean; notValidToken?: boolean } = {}
): Promise<StalkerProfileResponse> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
// Build metrics JSON matching working app
const metrics: Record<string, string> = {
mac: macAddress,
model: 'MAG250',
type: 'STB',
random: handshakeRandom,
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
: {}),
};
// Generate prehash for get_profile (same as handshake)
const prehash = await generatePrehash(macAddress);
const params: Record<string, string> = {
type: 'stb',
action: 'get_profile',
hd: '1',
not_valid_token: options.notValidToken ? '1' : '0',
video_out: 'hdmi',
auth_second_step: options.authSecondStep ? '1' : '0',
num_banks: '2',
metrics: JSON.stringify(metrics),
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
: {}),
...(normalizedIdentity.deviceId1
? { device_id: normalizedIdentity.deviceId1 }
: {}),
...(normalizedIdentity.deviceId2
? { device_id2: normalizedIdentity.deviceId2 }
: {}),
...(normalizedIdentity.signature1
? { signature: normalizedIdentity.signature1 }
: {}),
...(normalizedIdentity.signature2
? { signature2: normalizedIdentity.signature2 }
: {}),
prehash: prehash,
stb_type: '',
JsHttpRequest: '1-xml',
};
try {
const response =
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
assertNotAuthFailure(response);
return response;
} catch (error) {
this.logger.error('Get profile error:', error);
throw error;
}
}
/**
* Performs `do_auth` — the login/password step behind `get_profile`
* status 2. Returns the portal's bare boolean verdict.
*/
async doAuth(
portalUrl: string,
macAddress: string,
token: string,
credentials: StalkerPortalCredentials,
identity: StalkerPortalIdentity = {}
): Promise<boolean> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
const params: Record<string, string> = {
type: 'stb',
action: 'do_auth',
login: credentials.username ?? '',
password: credentials.password ?? '',
...(normalizedIdentity.deviceId1
? { device_id: normalizedIdentity.deviceId1 }
: {}),
...(normalizedIdentity.deviceId2
? { device_id2: normalizedIdentity.deviceId2 }
: {}),
JsHttpRequest: '1-xml',
};
try {
const response =
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
assertNotAuthFailure(response);
// do_auth returns { js: true } on success
return response?.js === true;
} catch (error) {
this.logger.error('do_auth error:', error);
throw error;
}
}
/**
* Full authentication flow: handshake → (optional token reuse) →
* get_profile → (status 2 → do_auth → get_profile with
* auth_second_step=1) → decoded profile.
*/
async authenticate(
portalUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {},
options: StalkerAuthenticateOptions = {}
): Promise<StalkerAuthenticationResult> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
const handshake = await this.performHandshake(
portalUrl,
macAddress,
normalizedIdentity,
options.storedToken
);
// An unchanged stored token is already an adopted session — the
// profile round trip is what token persistence saves. `not_valid`
// vetoes that shortcut: it is the portal saying the presented token
// is not a live session, and adopting it anyway would be
// unrecoverable — nothing writes a new token back, so every later
// start would re-present the same dead one.
if (
options.skipProfileWhenReused &&
options.storedToken &&
!handshake.notValid &&
handshake.token === options.storedToken
) {
return { token: handshake.token, reusedStoredToken: true };
}
const profile = await this.getProfile(
portalUrl,
macAddress,
handshake.token,
normalizedIdentity,
handshake.random,
{ authSecondStep: false, notValidToken: handshake.notValid }
);
// The envelope the login flow actually settled on: after a status-2
// `do_auth` that is the RETRIED profile, not the first one.
const profileResponse = await this.resolveProfile(
profile,
portalUrl,
macAddress,
handshake,
normalizedIdentity,
options
);
const resolved = profileResponse?.js;
return {
token: handshake.token,
accountInfo: resolved?.account_info,
profileResponse,
reusedStoredToken: false,
watchdogTimeoutSeconds: toFiniteNumber(resolved?.watchdog_timeout),
timeslotSeconds: toFiniteNumber(resolved?.timeslot),
};
}
/**
* Decodes `js.status` and drives the status-2 login flow. Throws a
* `StalkerPortalError` carrying the portal's own `msg`/`block_msg` text
* when the portal refused the session.
*/
private async resolveProfile(
profile: StalkerProfileResponse,
portalUrl: string,
macAddress: string,
handshake: StalkerHandshakeOutcome,
identity: StalkerPortalIdentity,
options: StalkerAuthenticateOptions
): Promise<StalkerProfileResponse> {
let settled = profile;
let js = profile?.js;
// Portals routinely stringify numeric fields (the same reason
// `watchdog_timeout`/`timeslot` are typed `number | string`), so a
// strict `=== 2` would read `"2"` as a healthy profile and skip the
// whole login flow.
if (toFiniteNumber(js?.status) === 2) {
const username = options.credentials?.username?.trim() ?? '';
const password = options.credentials?.password ?? '';
if (!username || !password) {
throw new StalkerPortalError('login-required');
}
const accepted = await this.doAuth(
portalUrl,
macAddress,
handshake.token,
{ username, password },
identity
);
if (!accepted) {
throw new StalkerPortalError('login-rejected');
}
const retried = await this.getProfile(
portalUrl,
macAddress,
handshake.token,
identity,
handshake.random,
{ authSecondStep: true, notValidToken: handshake.notValid }
);
settled = retried;
js = retried?.js;
if (toFiniteNumber(js?.status) === 2) {
throw new StalkerPortalError('login-rejected');
}
}
const portalText = combineStalkerPortalMessages(
js?.msg,
js?.block_msg
);
if (toFiniteNumber(js?.status) === 1 || portalText) {
this.logger.error('Profile error:', portalText ?? 'status 1');
throw new StalkerPortalError('blocked', portalText);
}
return settled;
}
}
@@ -101,7 +101,10 @@ describe('StalkerPortalDiscoveryService', () => {
expect(authenticate).toHaveBeenCalledWith(
'http://ministra.example/server/load.php',
MAC,
{ serialNumber: 'SN1' }
{ serialNumber: 'SN1' },
// Import credentials ride along for portals that answer
// get_profile with status 2 (login/password required).
{ credentials: undefined }
);
});
@@ -190,7 +193,8 @@ describe('StalkerPortalDiscoveryService', () => {
expect(authenticate).toHaveBeenCalledWith(
'http://gated.example/portal.php',
MAC,
{}
{},
{ credentials: undefined }
);
});
@@ -3,6 +3,7 @@ import { DataService } from '@iptvnator/services';
import { STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
StalkerPortalCredentials,
StalkerProfileResponse,
StalkerSessionService,
} from './stalker-session.service';
@@ -26,6 +27,13 @@ export interface StalkerPortalEndpointResolution {
token?: string;
/** Account block from the classification `get_profile` (full portals only). */
accountInfo?: StalkerProfileResponse['js']['account_info'];
/**
* Watchdog cadence the confirming `get_profile` advertised. Persisted at
* import because a later start reuses the token and skips the only
* response that carries it.
*/
watchdogTimeoutSeconds?: number;
timeslotSeconds?: number;
}
/**
@@ -95,7 +103,8 @@ export class StalkerPortalDiscoveryService {
async discover(
rawUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {}
identity: StalkerPortalIdentity = {},
options: { credentials?: StalkerPortalCredentials } = {}
): Promise<StalkerPortalDiscoveryOutcome> {
const candidates = buildStalkerEndpointCandidates(rawUrl);
let authRejection: StalkerPortalDiscoveryRejection | null = null;
@@ -114,7 +123,8 @@ export class StalkerPortalDiscoveryService {
const outcome = await this.confirmFullPortal(
candidate,
macAddress,
identity
identity,
options.credentials
);
if (outcome.status === 'resolved') {
return outcome;
@@ -158,7 +168,8 @@ export class StalkerPortalDiscoveryService {
const outcome = await this.confirmFullPortal(
candidate,
macAddress,
identity
identity,
options.credentials
);
if (outcome.status === 'resolved') {
return outcome;
@@ -184,7 +195,8 @@ export class StalkerPortalDiscoveryService {
private async confirmFullPortal(
candidate: string,
macAddress: string,
identity: StalkerPortalIdentity
identity: StalkerPortalIdentity,
credentials?: StalkerPortalCredentials
): Promise<
StalkerPortalEndpointResolution | StalkerPortalDiscoveryRejection
> {
@@ -193,7 +205,8 @@ export class StalkerPortalDiscoveryService {
this.stalkerSession.authenticate(
candidate,
macAddress,
identity
identity,
{ credentials }
),
AUTH_TIMEOUT_MS
);
@@ -215,6 +228,8 @@ export class StalkerPortalDiscoveryService {
isFullStalkerPortal: true,
token: auth.token,
accountInfo: auth.accountInfo,
watchdogTimeoutSeconds: auth.watchdogTimeoutSeconds,
timeslotSeconds: auth.timeslotSeconds,
};
} catch (error) {
return {
@@ -0,0 +1,55 @@
import {
asStalkerPortalError,
combineStalkerPortalMessages,
StalkerPortalError,
stripStalkerPortalMarkup,
} from './stalker-portal-error';
describe('stripStalkerPortalMarkup', () => {
it('strips the markup a real block_msg carries', () => {
expect(
stripStalkerPortalMarkup(
'Your STB is damaged.<br/> Call the provider.'
)
).toBe('Your STB is damaged. Call the provider.');
});
it('collapses whitespace and trims', () => {
expect(stripStalkerPortalMarkup(' a \n b ')).toBe('a b');
});
});
describe('combineStalkerPortalMessages', () => {
it('joins msg and block_msg', () => {
expect(
combineStalkerPortalMessages('device conflict', 'STB damaged')
).toBe('device conflict — STB damaged');
});
it('drops empty and duplicated parts', () => {
expect(combineStalkerPortalMessages('', undefined)).toBeUndefined();
expect(combineStalkerPortalMessages('same', 'same')).toBe('same');
});
});
describe('asStalkerPortalError', () => {
it('recognizes real instances', () => {
const error = new StalkerPortalError('blocked', 'text');
expect(asStalkerPortalError(error)).toBe(error);
});
it('recognizes a structurally equivalent object across chunk boundaries', () => {
const shaped = {
name: 'StalkerPortalError',
kind: 'login-required',
message: 'refused',
};
expect(asStalkerPortalError(shaped)?.kind).toBe('login-required');
});
it('rejects ordinary errors and non-errors', () => {
expect(asStalkerPortalError(new Error('nope'))).toBeNull();
expect(asStalkerPortalError('Access denied.')).toBeNull();
expect(asStalkerPortalError(null)).toBeNull();
});
});
@@ -0,0 +1,93 @@
import type { StalkerAuthFailureBody } from '@iptvnator/shared/interfaces';
/**
* Why a Stalker portal refused the session.
*
* - `login-required` — `get_profile` answered `status: 2`: the portal wants
* `do_auth` with a login/password, and none are stored for the playlist.
* - `login-rejected` — `do_auth` answered `{js: false}` (the operator billing
* script refused the credentials), or the profile still demanded a login
* after a successful `do_auth`.
* - `blocked` — `get_profile` answered `status: 1`: the account is blocked or
* the device identity conflicts. `msg`/`block_msg` explain why.
* - `auth-failed` — a request came back as one of the plain-text bodies
* (`Authorization failed.`, `Access denied.`, `Unauthorized request.`) and
* re-authentication did not recover it.
*/
export type StalkerPortalErrorKind =
| 'login-required'
| 'login-rejected'
| 'blocked'
| 'auth-failed';
/**
* `block_msg` routinely carries markup ("Your STB is damaged.<br/> Call the
* provider."); strip it before the text reaches a snackbar or error view.
*/
export function stripStalkerPortalMarkup(text: string): string {
return text
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Combines the portal's own `msg`/`block_msg` explanation into one plain-text
* line, or returns undefined when the portal sent none.
*/
export function combineStalkerPortalMessages(
msg: string | undefined,
blockMsg: string | undefined
): string | undefined {
const parts = [msg, blockMsg]
.map((part) => stripStalkerPortalMarkup(part ?? ''))
.filter((part) => part.length > 0);
// A duplicated msg/block_msg pair should not be echoed twice.
const unique = parts.filter(
(part, index) => parts.indexOf(part) === index
);
return unique.length > 0 ? unique.join(' — ') : undefined;
}
/**
* A portal-explained authentication failure. `portalText` carries the server's
* own words (already markup-stripped) when it sent any — the UI shows them
* verbatim instead of a generic "unable to load" message.
*/
export class StalkerPortalError extends Error {
constructor(
readonly kind: StalkerPortalErrorKind,
readonly portalText?: string,
readonly failureBody?: StalkerAuthFailureBody
) {
super(
`Stalker portal refused the session (${kind})` +
(portalText ? `: ${portalText}` : '')
);
this.name = 'StalkerPortalError';
}
}
/**
* Recognizes a StalkerPortalError across chunk boundaries: `instanceof` is
* checked first, but a structurally equivalent object (same name + kind) is
* accepted too so lazy-loaded consumers never mis-classify one.
*/
export function asStalkerPortalError(
error: unknown
): StalkerPortalError | null {
if (error instanceof StalkerPortalError) {
return error;
}
if (
typeof error === 'object' &&
error !== null &&
(error as { name?: unknown }).name === 'StalkerPortalError' &&
typeof (error as { kind?: unknown }).kind === 'string'
) {
return error as StalkerPortalError;
}
return null;
}
@@ -0,0 +1,48 @@
import {
isStalkerAuthFailureMessage,
isStalkerAuthFailureResponse,
} from '@iptvnator/shared/interfaces';
/**
* Checks whether a response or thrown error indicates a Stalker authorization
* failure.
*
* Every signal here is structural. `isStalkerAuthFailureResponse` covers the
* complete set of portal auth failures — the plain-text bodies, the transport
* marker Electron mints from them, and the JSON-envelope forms — and is
* shared with endpoint discovery and the lazy repair, so a phrase one layer
* classifies as an auth failure cannot be ignored by another: the session
* would otherwise keep an expired token and every later request fails.
*
* What is deliberately gone: the old `JSON.stringify(response)` regex sweep.
* It matched the phrase anywhere in an arbitrary payload — including inside
* legitimate catalog data — and existed only because the raw body reached the
* renderer unclassified. The transports classify now, so the shapes are known.
*/
export function isStalkerAuthorizationFailure(
responseOrError: unknown
): boolean {
if (!responseOrError) {
return false;
}
const response = responseOrError as Record<string, unknown>;
const message = response?.['message'];
if (
isStalkerAuthFailureResponse(responseOrError) ||
isStalkerAuthFailureMessage(message)
) {
return true;
}
// HTTP auth codes survive the IPC boundary only as message text
// (`HTTP Error 401: …`): the custom `status` property is stripped by
// ipcRenderer, so the numeric code must be read from the message.
if (typeof message === 'string' && /HTTP Error 40[13]\b/.test(message)) {
return true;
}
// Same code on a response object that never crossed IPC.
return response?.['status'] === 401 || response?.['status'] === 403;
}
@@ -0,0 +1,159 @@
import { firstValueFrom } from 'rxjs';
import type { Playlist } from '@iptvnator/shared/interfaces';
import type { PlaylistsService } from '@iptvnator/services';
import type { createLogger } from '@iptvnator/portal/shared/util';
import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller';
import type { StalkerAuthenticationResult } from './stalker-auth.api';
/**
* Session facts persisted with the playlist between app starts.
*
* `identityFingerprint` is what makes the token safe to re-present: it records
* the identity the session was negotiated for, so an edited MAC or serial
* cannot inherit the previous session.
*/
export interface PersistedStalkerSession {
token?: string;
identityFingerprint?: string;
watchdogTimeoutSeconds?: number;
timeslotSeconds?: number;
}
/**
* Reads and writes the Stalker session persisted on the playlist row.
*
* Kept out of the session service because it is the only part that needs the
* persistence stack, which is resolved lazily: the service is constructed
* during bootstrap, and pulling `PlaylistsService` in eagerly would both
* widen its dependency graph and risk a DI cycle.
*/
export class StalkerSessionStore {
constructor(
private readonly resolvePlaylistsService: () => PlaylistsService,
private readonly logger: ReturnType<typeof createLogger>
) {}
/** Reads a playlist row through the lazily-resolved persistence stack. */
async readRow(playlistId: string): Promise<Playlist | undefined> {
const row = await firstValueFrom(
this.resolvePlaylistsService().getPlaylistById(playlistId)
);
return (row as Playlist) ?? undefined;
}
/**
* Reads the session persisted with the playlist: the token, the identity
* it was negotiated for, and the watchdog cadence the portal advertised.
*
* The token is only offered for reuse when its identity still matches —
* re-presenting one minted for an edited MAC would pair a new identity
* with an old session, exactly what the in-memory cache guards against.
*/
async read(
playlist: Playlist,
fingerprint: string
): Promise<PersistedStalkerSession> {
const fromPlaylist: PersistedStalkerSession = {
token: playlist.stalkerToken || undefined,
identityFingerprint: playlist.stalkerSessionIdentity,
watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout,
timeslotSeconds: playlist.stalkerTimeslot,
};
// Shortcut only when the object carries the cadence too. Taking it on
// the token alone would silently drop a persisted cadence for any
// playlist shape that copies the token without the timing fields.
const stored =
fromPlaylist.token &&
fromPlaylist.watchdogTimeoutSeconds !== undefined
? fromPlaylist
: await this.readFromRow(playlist, fromPlaylist);
if (
stored.token &&
stored.identityFingerprint !== undefined &&
stored.identityFingerprint !== fingerprint
) {
// Minted for a different identity — negotiate a fresh session.
return { ...stored, token: undefined };
}
return stored;
}
private async readFromRow(
playlist: Playlist,
fallback: PersistedStalkerSession
): Promise<PersistedStalkerSession> {
try {
const stored = await this.readRow(playlist._id);
return {
// The row is authoritative, but a meta that carried a token
// the row has not seen yet must not lose it.
token: stored?.stalkerToken || fallback.token,
identityFingerprint:
stored?.stalkerSessionIdentity ??
fallback.identityFingerprint,
watchdogTimeoutSeconds: stored?.stalkerWatchdogTimeout,
timeslotSeconds: stored?.stalkerTimeslot,
};
} catch (error) {
this.logger.debug('Persisted session lookup failed:', error);
return fallback;
}
}
/**
* Writes a renegotiated session back, best effort.
*
* The EFFECTIVE cadence is stored, not the raw one: a portal that
* advertises nothing must still leave a value behind, or "no cadence
* stored" would keep meaning "never profiled" and every start would
* re-profile it. Stored absence therefore means exactly one thing — this
* playlist has never completed a profile.
*
* Returns the cadence that was applied, so the caller can drive the
* watchdog with the same numbers.
*/
write(
playlistId: string,
result: StalkerAuthenticationResult,
stored: PersistedStalkerSession,
fingerprint: string
): void {
const watchdogTimeout =
result.watchdogTimeoutSeconds ??
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS;
const timeslot = result.timeslotSeconds ?? 0;
const changed =
result.token !== stored.token ||
fingerprint !== stored.identityFingerprint ||
watchdogTimeout !== stored.watchdogTimeoutSeconds ||
timeslot !== stored.timeslotSeconds;
if (!changed) {
return;
}
try {
void firstValueFrom(
this.resolvePlaylistsService().updateStalkerSession(
playlistId,
{
stalkerToken: result.token,
stalkerSessionIdentity: fingerprint,
stalkerWatchdogTimeout: watchdogTimeout,
stalkerTimeslot: timeslot,
}
)
).catch((error) => {
this.logger.debug('Session write-back failed:', error);
});
} catch (error) {
// Persistence stack unavailable (e.g. isolated tests, or a DI
// cycle at this point in bootstrap). The session still works for
// this run; only the cross-restart reuse is lost.
this.logger.debug('Session write-back unavailable:', error);
}
}
}
@@ -2,7 +2,10 @@ import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { StalkerPortalError } from './stalker-portal-error';
import { STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS } from './stalker-watchdog.controller';
import {
stalkerIdentityFingerprint,
STALKER_SERIAL_NUMBER,
StalkerProfileResponse,
StalkerSessionService,
@@ -186,8 +189,10 @@ describe('StalkerSessionService identity-tagged token cache', () => {
);
const oldAuth = service.ensureToken(playlistA);
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
// ensureToken reads the persisted session before the handshake, so
// wait for the transport call rather than a fixed microtask count.
while (pendingResolvers.length === 0) {
await new Promise((resolve) => setTimeout(resolve));
}
const editedIdentity = {
@@ -198,10 +203,10 @@ describe('StalkerSessionService identity-tagged token cache', () => {
// Settle the OLD identity's handshake + profile.
pendingResolvers[0]({ js: { token: 'TOKEN-OLD', random: 'r' } });
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
while (pendingResolvers.length < 2) {
await new Promise((resolve) => setTimeout(resolve));
}
pendingResolvers[1]?.({ js: {} });
pendingResolvers[1]({ js: {} });
await expect(oldAuth).resolves.toMatchObject({ token: 'TOKEN-OLD' });
// The edited identity re-enters and negotiates its OWN session.
@@ -285,13 +290,19 @@ describe('StalkerSessionService identity-tagged token cache', () => {
service.setCachedToken('portal-1', 'DEAD', playlistA);
sendIpcEvent.mockResolvedValue('Authorization failed.');
// The typed refusal replaced the generic "Authorization failed after
// retry": callers need the portal's own reason to show the user.
await expect(
service.makeAuthenticatedRequest(
playlistA,
{ action: 'get_events' },
false
)
).rejects.toThrow('Authorization failed after retry');
).rejects.toMatchObject({
name: 'StalkerPortalError',
kind: 'auth-failed',
failureBody: 'Authorization failed.',
});
// Leaving the dead token cached would hand it to the next caller.
expect(service.getCachedToken('portal-1')).toBeNull();
@@ -374,6 +385,10 @@ describe('StalkerSessionService identity payloads', () => {
let service: StalkerSessionService;
let dataService: { sendIpcEvent: jest.Mock };
let playlistsService: {
getPlaylistById: jest.Mock;
updateStalkerSession: jest.Mock;
};
beforeEach(() => {
Object.defineProperty(globalThis, 'crypto', {
@@ -390,11 +405,16 @@ describe('StalkerSessionService identity payloads', () => {
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ js: {} }),
};
playlistsService = {
getPlaylistById: jest.fn().mockReturnValue(of(undefined)),
updateStalkerSession: jest.fn().mockReturnValue(of(null)),
};
TestBed.configureTestingModule({
providers: [
StalkerSessionService,
{ provide: DataService, useValue: dataService },
{ provide: PlaylistsService, useValue: playlistsService },
],
});
@@ -484,13 +504,19 @@ describe('StalkerSessionService identity payloads', () => {
stalkerSignature2: 'SIGNATURE-2',
} as Playlist);
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {
serialNumber: 'CUSTOMSN123',
deviceId1: 'DEVICE-ID-1',
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
});
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{
serialNumber: 'CUSTOMSN123',
deviceId1: 'DEVICE-ID-1',
deviceId2: 'DEVICE-ID-2',
signature1: 'SIGNATURE-1',
signature2: 'SIGNATURE-2',
},
// No cadence stored for this playlist, so the profile must run.
expect.objectContaining({ skipProfileWhenReused: false })
);
});
it('treats the legacy default serial number as absent during ensureToken', async () => {
@@ -507,7 +533,12 @@ describe('StalkerSessionService identity payloads', () => {
} as Playlist);
expect(result.serialNumber).toBeUndefined();
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {});
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({ skipProfileWhenReused: false })
);
});
it('serializes refreshAccountProfile behind an in-flight ensureToken', async () => {
@@ -536,8 +567,9 @@ describe('StalkerSessionService identity payloads', () => {
const refresh = service.refreshAccountProfile(playlist);
// Two handshakes must never overlap: on strict portals the second
// would invalidate the first one's token.
await Promise.resolve();
// would invalidate the first one's token. (Macrotask flush: the
// persisted-token lookup adds awaits before authenticate fires.)
await new Promise((resolve) => setTimeout(resolve));
expect(authenticate).toHaveBeenCalledTimes(1);
releaseFirst({ token: 'session-token' });
@@ -574,7 +606,7 @@ describe('StalkerSessionService identity payloads', () => {
const first = service.refreshAccountProfile(playlist);
const second = service.refreshAccountProfile(playlist);
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve));
expect(authenticate).toHaveBeenCalledTimes(1);
releases[0]({ token: 'session-token' });
@@ -708,6 +740,436 @@ describe('StalkerSessionService identity payloads', () => {
expect(accountInfo).toEqual({ login: 'user-2' });
});
it('re-presents the token persisted on the playlist during ensureToken', async () => {
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'STORED-TOKEN' });
await service.ensureToken({
_id: 'playlist-7',
portalUrl,
macAddress,
isFullStalkerPortal: true,
stalkerToken: 'STORED-TOKEN',
// With the cadence present the playlist is self-sufficient, so no
// row read is needed.
stalkerWatchdogTimeout: 120,
} as Playlist);
expect(playlistsService.getPlaylistById).not.toHaveBeenCalled();
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({
storedToken: 'STORED-TOKEN',
skipProfileWhenReused: true,
})
);
});
it('falls back to the stored playlist row for the persisted token', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({ _id: 'playlist-8', stalkerToken: 'ROW-TOKEN' } as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'ROW-TOKEN', reusedStoredToken: true });
await service.ensureToken({
_id: 'playlist-8',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
'playlist-8'
);
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({ storedToken: 'ROW-TOKEN' })
);
// A reused token was not renegotiated — nothing to write back.
expect(playlistsService.updateStalkerSession).not.toHaveBeenCalled();
});
it('writes a newly negotiated token back to the playlist', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({ _id: 'playlist-9', stalkerToken: 'OLD-TOKEN' } as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'NEW-TOKEN',
reusedStoredToken: false,
});
await service.ensureToken({
_id: 'playlist-9',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-9',
expect.objectContaining({ stalkerToken: 'NEW-TOKEN' })
);
});
it('applies the persisted watchdog cadence when the token is reused', async () => {
// Reuse skips the get_profile that carries the cadence, so without
// the persisted values the watchdog would sit on its 120 s default
// for the whole session — for a portal that advertised 60 s that is
// slower than before this feature existed.
const watchdog = (
service as unknown as {
watchdog: { applyProfileTiming: jest.Mock };
}
).watchdog;
jest.spyOn(watchdog, 'applyProfileTiming');
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'playlist-12',
stalkerToken: 'REUSED',
stalkerWatchdogTimeout: 60,
stalkerTimeslot: 7,
} as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'REUSED',
reusedStoredToken: true,
});
await service.ensureToken({
_id: 'playlist-12',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(watchdog.applyProfileTiming).toHaveBeenCalledWith(
'playlist-12',
{ watchdogTimeoutSeconds: 60, timeslotSeconds: 7 }
);
});
it('profiles a legacy token-only playlist instead of stranding it on the default', async () => {
// A playlist imported before the cadence was persisted has a
// reusable token and no cadence anywhere. Skipping the profile would
// leave it on the 120 s default permanently, because the profile is
// the only thing that could ever teach it otherwise.
playlistsService.getPlaylistById.mockReturnValue(
of({ _id: 'playlist-16', stalkerToken: 'LEGACY' } as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({
token: 'LEGACY',
reusedStoredToken: false,
watchdogTimeoutSeconds: 60,
timeslotSeconds: 5,
});
await service.ensureToken({
_id: 'playlist-16',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({
storedToken: 'LEGACY',
skipProfileWhenReused: false,
})
);
// ...and the learned cadence is persisted, so the next start skips.
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-16',
expect.objectContaining({
stalkerWatchdogTimeout: 60,
stalkerTimeslot: 5,
})
);
});
it('records the effective cadence when the portal advertises none', async () => {
// Otherwise "no cadence stored" would keep meaning "never profiled"
// and such a portal would be re-profiled on every single start.
playlistsService.getPlaylistById.mockReturnValue(
of({ _id: 'playlist-17', stalkerToken: 'LEGACY' } as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'LEGACY',
reusedStoredToken: false,
});
await service.ensureToken({
_id: 'playlist-17',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-17',
expect.objectContaining({
stalkerWatchdogTimeout:
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
stalkerTimeslot: 0,
})
);
});
it('refuses a persisted token minted for a different identity', async () => {
// The playlist was edited after the token was issued. Re-presenting
// it would pair the new identity with the old session — the same bug
// class the in-memory cache guards against, just across a restart.
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'playlist-identity',
stalkerToken: 'OLD-IDENTITY-TOKEN',
stalkerSessionIdentity: 'not-the-current-fingerprint',
stalkerWatchdogTimeout: 60,
} as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({ token: 'FRESH', reusedStoredToken: false });
await service.ensureToken({
_id: 'playlist-identity',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({ storedToken: undefined })
);
});
it('reuses a persisted token whose identity still matches', async () => {
const playlist = {
_id: 'playlist-identity-ok',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist;
playlistsService.getPlaylistById.mockReturnValue(
of({
...playlist,
stalkerToken: 'SAME-IDENTITY-TOKEN',
stalkerSessionIdentity: stalkerIdentityFingerprint(playlist),
stalkerWatchdogTimeout: 60,
} as Playlist)
);
const authenticate = jest
.spyOn(service, 'authenticate')
.mockResolvedValue({
token: 'SAME-IDENTITY-TOKEN',
reusedStoredToken: true,
});
await service.ensureToken(playlist);
expect(authenticate).toHaveBeenCalledWith(
portalUrl,
macAddress,
{},
expect.objectContaining({ storedToken: 'SAME-IDENTITY-TOKEN' })
);
});
it('reads the stored row when a token arrives without its cadence', async () => {
// A playlist shape that copies the token but not the timing fields
// must not silently downgrade the portal to the 120 s default.
const watchdog = (
service as unknown as {
watchdog: { applyProfileTiming: jest.Mock };
}
).watchdog;
jest.spyOn(watchdog, 'applyProfileTiming');
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'playlist-15',
stalkerToken: 'REUSED',
stalkerWatchdogTimeout: 45,
stalkerTimeslot: 3,
} as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'REUSED',
reusedStoredToken: true,
});
await service.ensureToken({
_id: 'playlist-15',
portalUrl,
macAddress,
isFullStalkerPortal: true,
// Token present, cadence absent — the shortcut must not fire.
stalkerToken: 'REUSED',
} as Playlist);
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
'playlist-15'
);
expect(watchdog.applyProfileTiming).toHaveBeenCalledWith(
'playlist-15',
{ watchdogTimeoutSeconds: 45, timeslotSeconds: 3 }
);
});
it('persists a freshly decoded watchdog cadence with the token', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({ _id: 'playlist-13' } as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'NEW-TOKEN',
reusedStoredToken: false,
watchdogTimeoutSeconds: 90,
timeslotSeconds: 12,
});
await service.ensureToken({
_id: 'playlist-13',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-13',
expect.objectContaining({
stalkerToken: 'NEW-TOKEN',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 12,
// The identity the session was negotiated for, so a later
// start can refuse to reuse it after an identity edit.
stalkerSessionIdentity: expect.any(String),
})
);
});
it('rewrites the session when only the cadence changed', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'playlist-14',
stalkerToken: 'SAME',
stalkerWatchdogTimeout: 120,
} as Playlist)
);
jest.spyOn(service, 'authenticate').mockResolvedValue({
token: 'SAME',
reusedStoredToken: false,
watchdogTimeoutSeconds: 45,
});
await service.ensureToken({
_id: 'playlist-14',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist);
expect(playlistsService.updateStalkerSession).toHaveBeenCalledWith(
'playlist-14',
expect.objectContaining({ stalkerWatchdogTimeout: 45 })
);
});
it('surfaces the portal failure body when the retry also fails', async () => {
const playlist = {
_id: 'playlist-10',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist;
jest.spyOn(service, 'ensureToken').mockResolvedValue({
token: 'token-1',
});
// The transport marker for a blocked account, on both attempts.
dataService.sendIpcEvent.mockResolvedValue({
stalkerAuthFailure: 'Access denied.',
});
await expect(
service.makeAuthenticatedRequest(playlist, {
type: 'vod',
action: 'get_categories',
})
).rejects.toMatchObject({
name: 'StalkerPortalError',
kind: 'auth-failed',
failureBody: 'Access denied.',
});
// One retry happened before giving up.
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
});
it('recognizes the raw PWA plain-text failure body and retries', async () => {
const playlist = {
_id: 'playlist-11',
portalUrl,
macAddress,
isFullStalkerPortal: true,
} as Playlist;
jest.spyOn(service, 'ensureToken')
.mockResolvedValueOnce({ token: 'stale' })
.mockResolvedValueOnce({ token: 'fresh' });
dataService.sendIpcEvent
.mockResolvedValueOnce('Unauthorized request.')
.mockResolvedValueOnce({ js: { data: [] } });
await expect(
service.makeAuthenticatedRequest(playlist, {
type: 'itv',
action: 'get_ordered_list',
})
).resolves.toEqual({ js: { data: [] } });
});
it('throws StalkerPortalError with the portal text when auth is refused', async () => {
// Blocked account: get_profile answers status 1 with msg/block_msg.
dataService.sendIpcEvent
.mockResolvedValueOnce({
js: { token: 'token-1', random: 'random-1' },
})
.mockResolvedValueOnce({
js: {
status: 1,
msg: 'device conflict - device_id mismatch',
block_msg: 'Your STB is damaged.<br/> Call the provider.',
},
});
await expect(
service.authenticate(portalUrl, macAddress)
).rejects.toMatchObject({
name: 'StalkerPortalError',
kind: 'blocked',
portalText:
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
});
});
it('keeps StalkerPortalError recognizable via instanceof', () => {
expect(new StalkerPortalError('blocked')).toBeInstanceOf(Error);
});
it('passes an explicit serial into the initial handshake request', async () => {
dataService.sendIpcEvent
.mockResolvedValueOnce({
@@ -1,6 +1,6 @@
import { Injectable, Injector, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
extractStalkerAuthFailureBody,
isFullStalkerPortalPlaylist,
isFullStalkerPortalUrl,
Playlist,
@@ -9,10 +9,6 @@ import {
} 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,
@@ -20,75 +16,41 @@ import {
stalkerIdentityFingerprint,
type StalkerPortalIdentity,
} from './stalker-identity.utils';
import {
StalkerAuthApi,
type StalkerAuthenticateOptions,
type StalkerAuthenticationResult,
type StalkerHandshakeResponse,
type StalkerPortalCredentials,
type StalkerProfileResponse,
} from './stalker-auth.api';
import { isStalkerAuthorizationFailure } from './stalker-response-classification';
import { StalkerPortalError } from './stalker-portal-error';
import {
StalkerSessionStore,
type PersistedStalkerSession,
} from './stalker-session-store';
import { StalkerTokenCache } from './stalker-token-cache';
import {
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
StalkerWatchdogController,
} from './stalker-watchdog.controller';
export { getStalkerPortalIdentityFromPlaylist, normalizeStalkerPortalIdentity };
export {
getStalkerPortalIdentityFromPlaylist,
normalizeStalkerPortalIdentity,
stalkerIdentityFingerprint,
};
export type { StalkerPortalIdentity };
/**
* SHA1 hash using native Web Crypto API
* Produces correct 40-character hex hash matching real Stalker clients
*/
async function sha1(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* Generates SHA1 prehash from MAC address
* This must match what real Stalker clients send
*/
async function generatePrehash(macAddress: string): Promise<string> {
// Use MAC address with colons, uppercase - this is what most clients use
const str = macAddress.toUpperCase();
return (await sha1(str)).toUpperCase();
}
/**
* Generates a random string for metrics
*/
function generateRandom(): string {
const chars = 'abcdef0123456789';
let result = '';
for (let i = 0; i < 40; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export type {
StalkerAuthenticateOptions,
StalkerAuthenticationResult,
StalkerHandshakeResponse,
StalkerPortalCredentials,
StalkerProfileResponse,
};
export const STALKER_SERIAL_NUMBER = LEGACY_DEFAULT_STALKER_SERIAL;
const STALKER_WATCHDOG_INTERVAL_MS = 25_000;
export interface StalkerHandshakeResponse {
js: {
token: string;
not_valid?: number;
random?: string;
};
}
export interface StalkerProfileResponse {
js: {
id?: string;
name?: string;
mac?: string;
status?: number;
msg?: string;
block_msg?: string;
account_info?: {
login?: string;
expire_date?: number;
tariff_plan_name?: string;
status?: number;
};
};
}
interface StalkerAuthConfirmationResponse {
js?: boolean;
}
/**
* Service to manage Stalker portal session tokens.
@@ -101,40 +63,25 @@ 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.
// when a session is resolved from (or written back to) the stored row.
private readonly injector = inject(Injector);
private readonly logger = createLogger('StalkerSession');
private readonly authApi = new StalkerAuthApi(this.dataService, this.logger);
private readonly sessionStore = new StalkerSessionStore(
() => this.injector.get(PlaylistsService),
this.logger
);
private readonly watchdog = new StalkerWatchdogController({
sendRequest: (playlist, params) =>
this.makeAuthenticatedRequest(playlist, params, false),
readPersistedPlaylist: (playlistId) =>
this.sessionStore.readRow(playlistId),
logger: this.logger,
});
// 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 — 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: Promise<{ token: string; serialNumber?: string }>;
identityFingerprint: string;
}
>();
private watchdogIntervals = new Map<
string,
ReturnType<typeof setInterval>
>();
private watchdogPlaylists = new Map<string, Playlist>();
private watchdogInFlight = new Set<string>();
private activeWatchdogPlaylistId: string | null = null;
// Session state for this run, identity-tagged so an edited playlist can
// inherit neither a cached token nor an in-flight authentication.
private readonly tokens = new StalkerTokenCache();
/**
* Checks if a URL looks like a full stalker portal URL (requires
@@ -149,10 +96,10 @@ export class StalkerSessionService {
/**
* 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).
* for playback fast paths that cannot supply an identity.
*/
getCachedToken(playlistId: string): string | null {
return this.tokenCache.get(playlistId)?.token || null;
return this.tokens.get(playlistId);
}
/**
@@ -164,60 +111,34 @@ export class StalkerSessionService {
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;
this.tokens.set(playlistId, token, identitySource);
}
/**
* Clears the cached token for a playlist (e.g., on auth failure)
*/
clearCachedToken(playlistId: string): void {
this.tokenCache.delete(playlistId);
this.tokens.clear(playlistId);
}
/**
* Lets the repair layer overlay its in-session override on the row a
* watchdog ping resolves.
*/
registerWatchdogPlaylistDecorator(
decorator: (playlist: Playlist) => Playlist
): void {
this.watchdog.registerPlaylistDecorator(decorator);
}
/**
* Sets which playlist should receive periodic watchdog pings.
* This keeps some Ministra/Stalker sessions alive for live playback.
* The cadence comes from the portal's profile (`watchdog_timeout` +
* `timeslot`), defaulting to the documented 120 s.
*/
setActiveWatchdogPlaylist(playlist?: Playlist | null): void {
const nextPlaylistId = playlist?._id ?? null;
if (
this.activeWatchdogPlaylistId &&
this.activeWatchdogPlaylistId !== nextPlaylistId
) {
this.stopWatchdog(this.activeWatchdogPlaylistId);
}
this.activeWatchdogPlaylistId = nextPlaylistId;
if (
!playlist ||
!isFullStalkerPortalPlaylist(playlist) ||
!playlist.portalUrl ||
!playlist.macAddress
) {
if (nextPlaylistId) {
this.stopWatchdog(nextPlaylistId);
}
return;
}
this.startWatchdog(playlist);
this.watchdog.setActivePlaylist(playlist);
}
/**
@@ -225,357 +146,105 @@ export class StalkerSessionService {
* 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).
* without waiting for the next route activation.
*
* Delegation is the contract: `setActiveWatchdogPlaylist` owns the
* start/stop/repoint logic, this only feeds it the fresh row.
*/
refreshActiveWatchdogPlaylist(playlist: Playlist): void {
if (this.activeWatchdogPlaylistId !== playlist._id) {
if (!this.watchdog.isActivePlaylist(playlist._id)) {
return;
}
this.setActiveWatchdogPlaylist(playlist);
}
private startWatchdog(playlist: Playlist): void {
const playlistId = playlist._id;
this.watchdogPlaylists.set(playlistId, playlist);
if (this.watchdogIntervals.has(playlistId)) {
return;
}
void this.sendWatchdogPing(playlistId, '1');
const intervalId = setInterval(() => {
void this.sendWatchdogPing(playlistId, '0');
}, STALKER_WATCHDOG_INTERVAL_MS);
this.watchdogIntervals.set(playlistId, intervalId);
}
private stopWatchdog(playlistId: string): void {
const intervalId = this.watchdogIntervals.get(playlistId);
if (intervalId) {
clearInterval(intervalId);
this.watchdogIntervals.delete(playlistId);
}
this.watchdogPlaylists.delete(playlistId);
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.
* Performs handshake to get a session token for a full stalker portal.
* An optional persisted token is re-presented: the handshake is
* idempotent, so a still-valid token comes back unchanged.
*/
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'
): Promise<void> {
if (this.watchdogInFlight.has(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,
{
type: 'watchdog',
action: 'get_events',
event_active_id: '0',
cur_play_type: '0',
init,
JsHttpRequest: '1-xml',
},
false
);
} catch (error) {
// Keep failures non-fatal; next interval can recover after token refresh.
this.logger.warn('Watchdog ping failed:', error);
} finally {
this.watchdogInFlight.delete(playlistId);
}
}
/**
* Performs handshake to get a session token for a full stalker portal
* Returns both the token and the random value for use in subsequent requests
*/
async performHandshake(
performHandshake(
portalUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {}
): Promise<{ token: string; random: string }> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
const prehash = await generatePrehash(macAddress);
const params: Record<string, string> = {
type: 'stb',
action: 'handshake',
token: '',
prehash,
JsHttpRequest: '1-xml',
};
try {
const response: StalkerHandshakeResponse =
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
if (response?.js?.token) {
return {
token: response.js.token,
random: response.js.random || generateRandom(),
};
}
this.logger.error('No token in response');
throw new Error('Handshake failed: No token received');
} catch (error) {
this.logger.error('Handshake error:', error);
throw error;
}
identity: StalkerPortalIdentity = {},
storedToken?: string
): Promise<{ token: string; random: string; notValid: boolean }> {
return this.authApi.performHandshake(
portalUrl,
macAddress,
identity,
storedToken
);
}
/**
* Gets account profile information to validate the portal and check subscription
* Based on working implementation from stalker-to-m3u repo
* Gets account profile information to validate the portal and check
* subscription.
*/
async getProfile(
getProfile(
portalUrl: string,
macAddress: string,
token: string,
identity: StalkerPortalIdentity,
handshakeRandom: string
handshakeRandom: string,
options: { authSecondStep?: boolean; notValidToken?: boolean } = {}
): Promise<StalkerProfileResponse> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
// Build metrics JSON matching working app
const metrics: Record<string, string> = {
mac: macAddress,
model: 'MAG250',
type: 'STB',
random: handshakeRandom,
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
: {}),
};
// Generate prehash for get_profile (same as handshake)
const prehash = await generatePrehash(macAddress);
// Profile request matching working StalkerTV app
// auth_second_step=1, includes metrics, prehash, and device_id params
const params: Record<string, string> = {
type: 'stb',
action: 'get_profile',
hd: '1',
not_valid_token: '0',
video_out: 'hdmi',
auth_second_step: '1',
num_banks: '2',
metrics: JSON.stringify(metrics),
...(normalizedIdentity.serialNumber
? { sn: normalizedIdentity.serialNumber }
: {}),
...(normalizedIdentity.deviceId1
? { device_id: normalizedIdentity.deviceId1 }
: {}),
...(normalizedIdentity.deviceId2
? { device_id2: normalizedIdentity.deviceId2 }
: {}),
...(normalizedIdentity.signature1
? { signature: normalizedIdentity.signature1 }
: {}),
...(normalizedIdentity.signature2
? { signature2: normalizedIdentity.signature2 }
: {}),
prehash: prehash,
stb_type: '',
JsHttpRequest: '1-xml',
};
try {
const response: StalkerProfileResponse =
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
...(normalizedIdentity.serialNumber
? { serialNumber: normalizedIdentity.serialNumber }
: {}),
}
);
return response;
} catch (error) {
this.logger.error('Get profile error:', error);
throw error;
}
}
/**
* Performs do_auth to authenticate the session after handshake
*/
async doAuth(
portalUrl: string,
macAddress: string,
token: string
): Promise<boolean> {
const params: Record<string, string> = {
type: 'stb',
action: 'do_auth',
login: '',
password: '',
JsHttpRequest: '1-xml',
};
try {
const response =
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
}
);
// do_auth returns { js: true } on success
if (response?.js === true) {
return true;
}
return false;
} catch (error) {
this.logger.error('do_auth error:', error);
throw error;
}
}
/**
* Performs full authentication flow: handshake -> get_profile (NO do_auth based on working traces)
* Returns the token and account info if successful
*/
async authenticate(
portalUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {}
): Promise<{
token: string;
accountInfo?: StalkerProfileResponse['js']['account_info'];
/** Raw envelope so callers can apply their own failure checks. */
profileResponse?: StalkerProfileResponse;
}> {
const normalizedIdentity = normalizeStalkerPortalIdentity(identity);
// Step 1: Handshake to get token and random
const { token, random } = await this.performHandshake(
return this.authApi.getProfile(
portalUrl,
macAddress,
normalizedIdentity
token,
identity,
handshakeRandom,
options
);
// Step 2: Get profile to activate token and get account info
// The random from handshake must be used in auth_second_step
try {
const profileResponse = await this.getProfile(
portalUrl,
macAddress,
token,
normalizedIdentity,
random
);
// Check for profile-level errors
if (profileResponse?.js?.msg || profileResponse?.js?.block_msg) {
const errorMsg =
profileResponse.js.msg ||
profileResponse.js.block_msg ||
'Unknown profile error';
this.logger.error('Profile error:', errorMsg);
throw new Error(`Profile error: ${errorMsg}`);
}
return {
token,
accountInfo: profileResponse?.js?.account_info,
profileResponse,
};
} catch (error) {
// Profile fetch failed - this is a real error, propagate it
this.logger.error('Profile fetch failed:', error);
throw error;
}
}
/**
* Ensures a valid token exists for a playlist, performing full auth if needed
* IMPORTANT: Based on working traces, each session needs handshake + get_profile
* Returns the token to use for requests, and the serial number to store
* Performs do_auth — the login/password step behind get_profile status 2.
*/
doAuth(
portalUrl: string,
macAddress: string,
token: string,
credentials: StalkerPortalCredentials,
identity: StalkerPortalIdentity = {}
): Promise<boolean> {
return this.authApi.doAuth(
portalUrl,
macAddress,
token,
credentials,
identity
);
}
/**
* Performs the full authentication flow: handshake → get_profile, driving
* the status-2 `do_auth` login flow when the portal demands credentials.
* Throws `StalkerPortalError` with the portal's own `msg`/`block_msg`
* text when the portal refuses the session.
*/
authenticate(
portalUrl: string,
macAddress: string,
identity: StalkerPortalIdentity = {},
options: StalkerAuthenticateOptions = {}
): Promise<StalkerAuthenticationResult> {
return this.authApi.authenticate(
portalUrl,
macAddress,
identity,
options
);
}
/**
* Ensures a valid token exists for a playlist, performing auth if needed.
* A previously persisted token is re-presented in the handshake first —
* while it is still the MAC's session token the portal returns it
* unchanged and the `get_profile` round trip is skipped entirely.
* Returns the token to use for requests, and the serial number to store.
*/
async ensureToken(
playlist: Playlist
@@ -586,33 +255,19 @@ export class StalkerSessionService {
}
const identity = getStalkerPortalIdentityFromPlaylist(playlist);
const fingerprint = stalkerIdentityFingerprint(playlist);
// 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);
// Only the session negotiated for THIS identity may be reused.
const cachedToken = this.tokens.takeFor(playlist._id, fingerprint);
if (cachedToken) {
return { token: cachedToken, serialNumber: identity.serialNumber };
}
// Check if there's already a pending authentication for this playlist
// This prevents race conditions when multiple resources request a token simultaneously
const pendingEntry = this.pendingAuth.get(playlist._id);
const pendingEntry = this.tokens.getPending(playlist._id);
if (pendingEntry) {
if (
pendingEntry.identityFingerprint ===
stalkerIdentityFingerprint(playlist)
) {
if (pendingEntry.identityFingerprint === fingerprint) {
this.logger.debug('Waiting for pending authentication...');
return pendingEntry.promise;
}
@@ -629,8 +284,6 @@ export class StalkerSessionService {
return this.ensureToken(playlist);
}
// No cached token - need to do full authentication (handshake + get_profile)
// Don't trust stored tokens as they may be from a different session
if (!playlist.portalUrl || !playlist.macAddress) {
this.logger.error('Missing portal URL or MAC address');
throw new Error('Portal URL and MAC address are required');
@@ -642,23 +295,51 @@ export class StalkerSessionService {
// Use async/await wrapper to properly clean up on both success and failure
const authPromise = (async () => {
try {
const { token } = await this.authenticate(
const stored = await this.sessionStore.read(
playlist,
fingerprint
);
const result = await this.authenticate(
portalUrl,
macAddress,
identity
identity,
{
storedToken: stored.token,
credentials: {
username: playlist.username,
password: playlist.password,
},
// Only skip the profile once the cadence is known. A
// playlist stored before the cadence was persisted
// has a reusable token and no cadence, and skipping
// would strand it on the 120 s default forever — the
// profile is the only thing that could teach it. One
// request, once; every later start skips.
skipProfileWhenReused:
stored.watchdogTimeoutSeconds !== undefined,
}
);
this.setCachedToken(playlist._id, token, playlist);
return { token, serialNumber: identity.serialNumber };
this.setCachedToken(playlist._id, result.token, playlist);
this.applyProfileOutcome(
playlist._id,
result,
stored,
fingerprint
);
return {
token: result.token,
serialNumber: identity.serialNumber,
};
} finally {
// Clean up pending promise regardless of success/failure
this.pendingAuth.delete(playlist._id);
this.tokens.clearPending(playlist._id);
}
})();
// Store the pending promise so other concurrent requests can wait on it
this.pendingAuth.set(playlist._id, {
this.tokens.setPending(playlist._id, {
promise: authPromise,
identityFingerprint: stalkerIdentityFingerprint(playlist),
identityFingerprint: fingerprint,
});
return authPromise;
@@ -685,14 +366,15 @@ export class StalkerSessionService {
const portalUrl = playlist.portalUrl;
const macAddress = playlist.macAddress;
const identity = getStalkerPortalIdentityFromPlaylist(playlist);
const fingerprint = stalkerIdentityFingerprint(playlist);
// Claim the per-playlist slot. Re-check after every await: one
// settled promise releases every waiter at once, so a single
// pre-check would let them all start competing handshakes.
for (
let inFlight = this.pendingAuth.get(playlist._id);
let inFlight = this.tokens.getPending(playlist._id);
inFlight;
inFlight = this.pendingAuth.get(playlist._id)
inFlight = this.tokens.getPending(playlist._id)
) {
this.logger.debug('Waiting for pending authentication...');
// A failed pending auth must not abort the refresh; this call
@@ -718,11 +400,8 @@ 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);
const slotEntry = {
promise: slot,
identityFingerprint: stalkerIdentityFingerprint(playlist),
};
this.pendingAuth.set(playlist._id, slotEntry);
const slotEntry = { promise: slot, identityFingerprint: fingerprint };
this.tokens.setPending(playlist._id, slotEntry);
// ensureToken() reads tokenCache before pendingAuth, so leaving the
// old token there would hand a token this handshake is about to
@@ -734,9 +413,29 @@ export class StalkerSessionService {
const result = await this.authenticate(
portalUrl,
macAddress,
identity
identity,
{
credentials: {
username: playlist.username,
password: playlist.password,
},
}
);
this.setCachedToken(playlist._id, result.token, playlist);
// This path always ran a real get_profile, so the decoded cadence
// is authoritative; the previous values are only the write-back
// comparison baseline.
this.applyProfileOutcome(
playlist._id,
result,
{
token: playlist.stalkerToken,
identityFingerprint: playlist.stalkerSessionIdentity,
watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout,
timeslotSeconds: playlist.stalkerTimeslot,
},
fingerprint
);
settleSlot({
token: result.token,
serialNumber: identity.serialNumber,
@@ -748,70 +447,37 @@ 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) === slotEntry) {
this.pendingAuth.delete(playlist._id);
}
this.tokens.clearPendingIf(playlist._id, slotEntry);
}
}
/**
* Clears the cached token only while it is still the one that just
* failed. A request dispatched with the previous token can see its
* authorization failure arrive after a profile refresh has already
* cached a fresh token — deleting blindly would kill that fresh token
* and trigger a cascade of competing handshakes on strict portals.
* Propagates a successful authentication into session-side state.
*
* Reusing a stored token skips the `get_profile` that carries the
* watchdog cadence, so the persisted cadence is applied instead —
* otherwise a portal advertising a non-default `watchdog_timeout` would
* sit on the 120 s fallback for the whole session.
*/
private retireFailedToken(
private applyProfileOutcome(
playlistId: string,
failedToken: string | null
result: StalkerAuthenticationResult,
stored: PersistedStalkerSession,
fingerprint: string
): void {
if (failedToken && this.getCachedToken(playlistId) === failedToken) {
this.clearCachedToken(playlistId);
}
}
/**
* Checks if a response or error indicates an authorization failure
*/
private isAuthorizationError(responseOrError: unknown): boolean {
if (!responseOrError) return false;
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;
if (result.reusedStoredToken) {
this.watchdog.applyProfileTiming(playlistId, {
watchdogTimeoutSeconds: stored.watchdogTimeoutSeconds,
timeslotSeconds: stored.timeslotSeconds,
});
return;
}
// 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;
}
// Everything above is structural. What used to follow was a
// `JSON.stringify(responseOrError)` sweep with an UNANCHORED
// `authorization failed` pattern — the same false positive the body
// detector was just anchored against, reachable through a different
// door: a short proxy/WAF page containing the phrase retired the
// token, retried, and threw `Authorization failed after retry`,
// whose own message then matched the repair trigger and re-probed a
// portal that never refused anything. The shared detector already
// covers every real shape, including the `js.error`/`js.msg`
// envelopes, so the sweep only added the false positive.
return response?.['status'] === 401 || response?.['status'] === 403;
this.watchdog.applyProfileTiming(playlistId, {
watchdogTimeoutSeconds: result.watchdogTimeoutSeconds,
timeslotSeconds: result.timeslotSeconds,
});
this.sessionStore.write(playlistId, result, stored, fingerprint);
}
/**
@@ -839,12 +505,15 @@ export class StalkerSessionService {
);
// Check for authorization failure in response
if (this.isAuthorizationError(response)) {
if (isStalkerAuthorizationFailure(response)) {
// 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)) {
this.tokens.retireFailed(playlist._id, token);
if (
retryOnAuthFailure &&
isFullStalkerPortalPlaylist(playlist)
) {
// Retry once with fresh authentication
return this.makeAuthenticatedRequest<T>(
playlist,
@@ -853,16 +522,22 @@ export class StalkerSessionService {
);
}
throw new Error('Authorization failed after retry');
const failureBody =
extractStalkerAuthFailureBody(response) ?? undefined;
throw new StalkerPortalError(
'auth-failed',
failureBody,
failureBody
);
}
return response;
} catch (error) {
// Check if error indicates auth failure
if (this.isAuthorizationError(error)) {
if (isStalkerAuthorizationFailure(error)) {
// Same rule as above: a failed session is retired even on
// the no-retry path.
this.retireFailedToken(playlist._id, token);
this.tokens.retireFailed(playlist._id, token);
if (
retryOnAuthFailure &&
isFullStalkerPortalPlaylist(playlist)
@@ -0,0 +1,97 @@
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { stalkerIdentityFingerprint } from './stalker-identity.utils';
export interface StalkerPendingAuth {
promise: Promise<{ token: string; serialNumber?: string }>;
identityFingerprint: string;
}
/**
* In-memory session state for the current app run, keyed by playlist ID and
* tagged with the identity fingerprint the session was negotiated for.
*
* The tagging is the point: a playlist whose MAC, serial or device ids were
* edited must never inherit the previous session — neither the cached token
* nor an authentication that is still in flight for the old identity.
*/
export class StalkerTokenCache {
private readonly tokens = new Map<
string,
{ token: string; identityFingerprint: string }
>();
private readonly pending = new Map<string, StalkerPendingAuth>();
/**
* The cached token regardless of identity. Playback fast paths that
* cannot supply an identity use this; `takeFor` is the checked accessor.
*/
get(playlistId: string): string | null {
return this.tokens.get(playlistId)?.token || null;
}
set(
playlistId: string,
token: string,
identitySource: PlaylistMeta
): void {
this.tokens.set(playlistId, {
token,
identityFingerprint: stalkerIdentityFingerprint(identitySource),
});
}
clear(playlistId: string): void {
this.tokens.delete(playlistId);
}
/**
* The cached token when it was negotiated for this exact identity. A
* mismatch retires the entry and returns null, so the caller
* authenticates as the current identity instead of pairing a new one
* with an old session.
*/
takeFor(playlistId: string, fingerprint: string): string | null {
const cached = this.tokens.get(playlistId);
if (!cached) {
return null;
}
if (cached.identityFingerprint === fingerprint) {
return cached.token;
}
this.clear(playlistId);
return null;
}
/**
* Clears the cached token only while it is still the one that just
* failed. A request dispatched with the previous token can see its
* authorization failure arrive after a profile refresh has already
* cached a fresh token — deleting blindly would kill that fresh token
* and trigger a cascade of competing handshakes on strict portals.
*/
retireFailed(playlistId: string, failedToken: string | null): void {
if (failedToken && this.get(playlistId) === failedToken) {
this.clear(playlistId);
}
}
getPending(playlistId: string): StalkerPendingAuth | undefined {
return this.pending.get(playlistId);
}
setPending(playlistId: string, entry: StalkerPendingAuth): void {
this.pending.set(playlistId, entry);
}
clearPending(playlistId: string): void {
this.pending.delete(playlistId);
}
/** Retires the pending slot only when it is still the caller's own. */
clearPendingIf(playlistId: string, entry: StalkerPendingAuth): void {
if (this.pending.get(playlistId) === entry) {
this.pending.delete(playlistId);
}
}
}
@@ -0,0 +1,168 @@
import type { Playlist } from '@iptvnator/shared/interfaces';
import type { createLogger } from '@iptvnator/portal/shared/util';
import { StalkerWatchdogController } from './stalker-watchdog.controller';
describe('StalkerWatchdogController', () => {
const playlist = {
_id: 'playlist-1',
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
macAddress: '00:1A:79:AA:BB:CC',
isFullStalkerPortal: true,
} as Playlist;
let sendRequest: jest.Mock;
let readPersistedPlaylist: jest.Mock;
let controller: StalkerWatchdogController;
const logger = {
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
} as unknown as ReturnType<typeof createLogger>;
beforeEach(() => {
jest.useFakeTimers();
sendRequest = jest.fn().mockResolvedValue({ js: {} });
// The row is the source of truth for each ping; specs that care
// about mid-session edits override this per test.
readPersistedPlaylist = jest.fn().mockResolvedValue(playlist);
controller = new StalkerWatchdogController({
sendRequest,
readPersistedPlaylist,
logger,
});
});
afterEach(() => {
controller.setActivePlaylist(null);
jest.useRealTimers();
});
/** Lets the async sendPing settle between timer advances. */
async function flush(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
it('pings immediately with init=1, then every 120 s by default', async () => {
controller.setActivePlaylist(playlist);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
expect(sendRequest).toHaveBeenCalledWith(
playlist,
expect.objectContaining({
action: 'get_events',
type: 'watchdog',
init: '1',
})
);
// The legacy cadence was 25 s — nothing may fire that early.
jest.advanceTimersByTime(25_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(95_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(2);
expect(sendRequest.mock.calls[1][1]).toEqual(
expect.objectContaining({ init: '0' })
);
});
it('reschedules to the profile cadence with the timeslot offset', async () => {
controller.setActivePlaylist(playlist);
await flush();
sendRequest.mockClear();
controller.applyProfileTiming(playlist._id, {
watchdogTimeoutSeconds: 90,
timeslotSeconds: 10,
});
// Timeslot offset: nothing during the first 10 s...
jest.advanceTimersByTime(9_000);
await flush();
expect(sendRequest).not.toHaveBeenCalled();
// ...then the 90 s cadence starts.
jest.advanceTimersByTime(91_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(90_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(2);
});
it('keeps the stored cadence when the watchdog restarts', async () => {
controller.applyProfileTiming(playlist._id, {
watchdogTimeoutSeconds: 60,
timeslotSeconds: 0,
});
controller.setActivePlaylist(playlist);
await flush();
sendRequest.mockClear();
jest.advanceTimersByTime(60_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
});
it('clamps a garbage watchdog_timeout instead of adopting it', async () => {
controller.applyProfileTiming(playlist._id, {
watchdogTimeoutSeconds: 1,
timeslotSeconds: 0,
});
controller.setActivePlaylist(playlist);
await flush();
sendRequest.mockClear();
// Clamped to the 30 s floor — a 1 s cadence would hammer the portal.
jest.advanceTimersByTime(29_000);
await flush();
expect(sendRequest).not.toHaveBeenCalled();
jest.advanceTimersByTime(1_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
});
it('logs and continues when a ping fails — never retries or escalates', async () => {
sendRequest.mockRejectedValue(new Error('portal down'));
controller.setActivePlaylist(playlist);
await flush();
expect(logger.warn).toHaveBeenCalled();
expect(sendRequest).toHaveBeenCalledTimes(1);
// No immediate retry: the next attempt is the next scheduled tick.
jest.advanceTimersByTime(119_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(1_000);
await flush();
expect(sendRequest).toHaveBeenCalledTimes(2);
});
it('stops pinging when the active playlist is cleared', async () => {
controller.setActivePlaylist(playlist);
await flush();
sendRequest.mockClear();
controller.setActivePlaylist(null);
jest.advanceTimersByTime(600_000);
await flush();
expect(sendRequest).not.toHaveBeenCalled();
});
it('never pings for a simple (non-full) portal', async () => {
controller.setActivePlaylist({
...playlist,
isFullStalkerPortal: false,
} as Playlist);
jest.advanceTimersByTime(600_000);
await flush();
expect(sendRequest).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,294 @@
import {
isFullStalkerPortalPlaylist,
type Playlist,
} from '@iptvnator/shared/interfaces';
import type { createLogger } from '@iptvnator/portal/shared/util';
/**
* The portal expects `get_events` every `watchdog_timeout` seconds — 120 by
* default — not every 25 s as the client historically pinged.
*/
export const STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS = 120;
/** Guard rails for a garbage `watchdog_timeout` echoed by a broken panel. */
const MIN_PERIOD_SECONDS = 30;
const MAX_PERIOD_SECONDS = 3600;
export interface StalkerWatchdogTiming {
periodSeconds: number;
/** Per-user jitter: delays the first periodic ping so clients spread out. */
timeslotSeconds: number;
}
export interface StalkerWatchdogDeps {
/** Sends one authenticated `get_events` ping. */
sendRequest: (
playlist: Playlist,
params: Record<string, string | number>
) => Promise<unknown>;
/**
* Reads the persisted row for a playlist. The row is the source of truth
* for the configuration a ping authenticates as; see `resolvePlaylist`.
*/
readPersistedPlaylist: (playlistId: string) => Promise<Playlist | undefined>;
logger: ReturnType<typeof createLogger>;
}
interface WatchdogTimers {
timeslotTimeout?: ReturnType<typeof setTimeout>;
interval?: ReturnType<typeof setInterval>;
}
/**
* Keeps the active full portal's session "online" by pinging `get_events`.
*
* The cadence comes from the profile (`watchdog_timeout` + `timeslot`); until
* a profile has been decoded the documented default of 120 s applies. A missed
* ping never invalidates authentication — it only affects the portal's
* "online" reporting — so failures are logged and never retried or escalated.
*/
export class StalkerWatchdogController {
private readonly timers = new Map<string, WatchdogTimers>();
/** Activation-time snapshot; only a fallback when the row cannot be read. */
private readonly playlists = new Map<string, Playlist>();
private readonly inFlight = new Set<string>();
/** Survives stop/start: the cadence is a per-portal fact, not session state. */
private readonly timings = new Map<string, StalkerWatchdogTiming>();
private activePlaylistId: string | null = null;
private playlistDecorator: ((playlist: Playlist) => Playlist) | null = null;
constructor(private readonly deps: StalkerWatchdogDeps) {}
/**
* Sets which playlist should receive periodic watchdog pings.
* This keeps some Ministra/Stalker sessions alive for live playback.
*/
setActivePlaylist(playlist?: Playlist | null): void {
const nextPlaylistId = playlist?._id ?? null;
if (this.activePlaylistId && this.activePlaylistId !== nextPlaylistId) {
this.stop(this.activePlaylistId);
}
this.activePlaylistId = nextPlaylistId;
if (
!playlist ||
!isFullStalkerPortalPlaylist(playlist) ||
!playlist.portalUrl ||
!playlist.macAddress
) {
if (nextPlaylistId) {
this.stop(nextPlaylistId);
}
return;
}
this.start(playlist);
}
/** Whether this playlist currently owns the watchdog. */
isActivePlaylist(playlistId: string): boolean {
return this.activePlaylistId === playlistId;
}
/**
* Lets the repair layer overlay its in-session override on the row a ping
* resolves: the persisted row can still carry a pre-repair configuration
* while persistence is pending (or failed), and pinging that would stop
* or misdirect the keepalive.
*/
registerPlaylistDecorator(decorator: (playlist: Playlist) => Playlist): void {
this.playlistDecorator = decorator;
}
/**
* Applies the cadence decoded from a `get_profile` response. When the
* playlist's watchdog is already running on a different cadence, its
* periodic schedule restarts (without re-sending the init ping).
*/
applyProfileTiming(
playlistId: string,
timing: { watchdogTimeoutSeconds?: number; timeslotSeconds?: number }
): void {
const next = normalizeTiming(timing);
const current = this.timings.get(playlistId);
if (
current &&
current.periodSeconds === next.periodSeconds &&
current.timeslotSeconds === next.timeslotSeconds
) {
return;
}
this.timings.set(playlistId, next);
if (this.timers.has(playlistId)) {
this.clearTimers(playlistId);
this.timers.set(playlistId, {});
this.schedule(playlistId);
}
}
private start(playlist: Playlist): void {
const playlistId = playlist._id;
this.playlists.set(playlistId, playlist);
if (this.timers.has(playlistId)) {
return;
}
this.timers.set(playlistId, {});
void this.sendPing(playlistId, '1');
this.schedule(playlistId);
}
private stop(playlistId: string): void {
this.clearTimers(playlistId);
this.timers.delete(playlistId);
this.playlists.delete(playlistId);
this.inFlight.delete(playlistId);
}
private clearTimers(playlistId: string): void {
const timers = this.timers.get(playlistId);
if (!timers) {
return;
}
if (timers.timeslotTimeout) {
clearTimeout(timers.timeslotTimeout);
}
if (timers.interval) {
clearInterval(timers.interval);
}
}
private timingFor(playlistId: string): StalkerWatchdogTiming {
return (
this.timings.get(playlistId) ?? {
periodSeconds: STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
timeslotSeconds: 0,
}
);
}
private schedule(playlistId: string): void {
const timers = this.timers.get(playlistId);
if (!timers) {
return;
}
const { periodSeconds, timeslotSeconds } = this.timingFor(playlistId);
const startInterval = () => {
const current = this.timers.get(playlistId);
if (!current) {
return;
}
current.timeslotTimeout = undefined;
current.interval = setInterval(() => {
void this.sendPing(playlistId, '0');
}, periodSeconds * 1000);
};
if (timeslotSeconds > 0) {
timers.timeslotTimeout = setTimeout(
startInterval,
timeslotSeconds * 1000
);
} else {
startInterval();
}
}
/**
* The playlist a ping authenticates as. The persisted row is the source of
* truth: portal metadata (endpoint, mode, MAC, identity) edited or
* repaired mid-session must reach the keepalive within one cycle — the
* activation-time snapshot is only the fallback when the row cannot be
* read.
*/
private async resolvePlaylist(
playlistId: string
): Promise<Playlist | undefined> {
try {
const row = await this.deps.readPersistedPlaylist(playlistId);
if (row) {
// Keep the fallback snapshot fresh for the next cycle.
this.playlists.set(playlistId, row);
return this.decorate(row);
}
} catch {
// Store unavailable (e.g. isolated tests): keep the snapshot.
}
const snapshot = this.playlists.get(playlistId);
return snapshot ? this.decorate(snapshot) : snapshot;
}
private decorate(playlist: Playlist): Playlist {
return this.playlistDecorator
? this.playlistDecorator(playlist)
: playlist;
}
private async sendPing(playlistId: string, init: '0' | '1'): Promise<void> {
if (this.inFlight.has(playlistId)) {
return;
}
// Claimed BEFORE the row read: it awaits, and two overlapping pings
// passing the check together would double-fire the keepalive.
this.inFlight.add(playlistId);
try {
const playlist = await this.resolvePlaylist(playlistId);
if (
!playlist ||
!playlist.portalUrl ||
!playlist.macAddress ||
!isFullStalkerPortalPlaylist(playlist)
) {
this.stop(playlistId);
return;
}
await this.deps.sendRequest(playlist, {
type: 'watchdog',
action: 'get_events',
event_active_id: '0',
cur_play_type: '0',
init,
JsHttpRequest: '1-xml',
});
} catch (error) {
// Keep failures non-fatal; the next tick can recover after a
// token refresh. Never retry or escalate: a missed ping does not
// invalidate authentication.
this.deps.logger.warn('Watchdog ping failed:', error);
} finally {
this.inFlight.delete(playlistId);
}
}
}
function normalizeTiming(timing: {
watchdogTimeoutSeconds?: number;
timeslotSeconds?: number;
}): StalkerWatchdogTiming {
const period = clamp(
Math.floor(
timing.watchdogTimeoutSeconds ??
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS
),
MIN_PERIOD_SECONDS,
MAX_PERIOD_SECONDS
);
const timeslot = clamp(Math.floor(timing.timeslotSeconds ?? 0), 0, period - 1);
return { periodSeconds: period, timeslotSeconds: timeslot };
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return min;
}
return Math.min(Math.max(value, min), max);
}
@@ -441,6 +441,80 @@ describe('PlaylistsService', () => {
).toBe('1');
});
it('clears a stale watchdog cadence when the portal stops advertising one', async () => {
// Reusing the token skips the profile that would correct the cadence,
// so leaving the old value on the row means the next restart applies
// a cadence the portal no longer asks for.
const existingPlaylist: Playlist = {
_id: 'stalker-1',
title: 'Stalker',
count: 0,
importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(),
autoRefresh: false,
stalkerToken: 'OLD',
stalkerWatchdogTimeout: 45,
stalkerTimeslot: 7,
} as Playlist;
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(existingPlaylist)),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
};
testWindow.electron = undefined;
const service = createService(dbService);
await firstValueFrom(
service.updateStalkerSession('stalker-1', { stalkerToken: 'NEW' })
);
const written = dbService.update.mock.calls[0][1] as Playlist;
expect(written.stalkerToken).toBe('NEW');
expect(written.stalkerWatchdogTimeout).toBeUndefined();
expect(written.stalkerTimeslot).toBeUndefined();
});
it('persists the watchdog cadence alongside the stalker token', async () => {
const existingPlaylist: Playlist = {
_id: 'stalker-1',
title: 'Stalker',
count: 0,
importDate: new Date('2026-08-02T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-08-02T00:00:00.000Z').toISOString(),
autoRefresh: false,
} as Playlist;
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(existingPlaylist)),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
};
testWindow.electron = undefined;
const service = createService(dbService);
await firstValueFrom(
service.updateStalkerSession('stalker-1', {
stalkerToken: 'TOKEN',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 12,
})
);
expect(dbService.update).toHaveBeenCalledWith(
DbStores.Playlists,
expect.objectContaining({
stalkerToken: 'TOKEN',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 12,
})
);
});
it('persists hiddenGroupTitles in playlist meta updates', async () => {
const existingPlaylist: Playlist = {
_id: 'playlist-1',
@@ -729,6 +729,45 @@ export class PlaylistsService {
});
}
/**
* Persists a freshly negotiated Stalker session on the playlist so the
* next app start can re-present the token (the portal handshake is
* idempotent) and keep the portal's own watchdog cadence — reusing a
* token skips the `get_profile` that carries the cadence, so it has to
* survive with the token. No-ops when the playlist row does not exist
* yet: the import flow saves both with the playlist itself.
*/
updateStalkerSession(
playlistId: string,
session: {
stalkerToken: string;
stalkerSessionIdentity?: string;
stalkerWatchdogTimeout?: number;
stalkerTimeslot?: number;
}
) {
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (!playlist) {
return null;
}
// The cadence is written unconditionally, including as
// `undefined`: a portal that stops advertising one must not leave
// a stale value behind for the next restart to re-apply, since
// reusing the token skips the profile that would correct it.
return this.persistPlaylistMutation({
...playlist,
stalkerToken: session.stalkerToken,
stalkerSessionIdentity: session.stalkerSessionIdentity,
stalkerWatchdogTimeout: session.stalkerWatchdogTimeout,
stalkerTimeslot: session.stalkerTimeslot,
});
});
}
updateFavorites(id: string, favorites: string[]) {
return this.serializePlaylistWrite(id, async () => {
const playlist = await firstValueFrom(this.getPlaylistById(id));
@@ -58,6 +58,23 @@ export interface Playlist {
serverTimezone?: string;
/** Session token for full stalker portal authentication - persisted for session */
stalkerToken?: string;
/**
* Identity fingerprint the persisted `stalkerToken` was negotiated for.
* Re-presenting a token minted for a DIFFERENT identity (the user edited
* the MAC, serial or device ids) would pair a new identity with an old
* session — the same class of bug the in-memory cache guards against, so
* reuse is refused unless this still matches the playlist.
*/
stalkerSessionIdentity?: string;
/**
* Watchdog cadence the portal advertised in `get_profile`, persisted
* alongside the token: reusing a stored token skips the profile request
* that carries these, so without persistence the keep-alive would fall
* back to the 120 s default forever.
*/
stalkerWatchdogTimeout?: number;
/** Per-user watchdog jitter (seconds) from `get_profile`. */
stalkerTimeslot?: number;
/** Serial number for stalker portal - generated once and stored for consistency */
stalkerSerialNumber?: string;
/** Optional device ID 1 for stalker portal - if not provided, auto-generated from MAC */
@@ -1,5 +1,7 @@
import {
classifyStalkerAuthFailureBody,
createStalkerAuthFailureMarker,
extractStalkerAuthFailureBody,
isStalkerAuthFailureBody,
isStalkerAuthFailureMessage,
isStalkerAuthFailureResponse,
@@ -98,6 +100,58 @@ describe('isStalkerAuthFailureResponse', () => {
});
});
describe('transport marker', () => {
it('round-trips through create / detect / extract', () => {
const marker = createStalkerAuthFailureMarker('Access denied.');
expect(isStalkerAuthFailureResponse(marker)).toBe(true);
expect(extractStalkerAuthFailureBody(marker)).toBe('Access denied.');
});
it('is what keeps the repair trigger working once Electron classifies', () => {
// The main process replaces the plain-text body with this marker, so
// a detector that only knew the raw string would silently stop
// triggering portal repair on the desktop app.
expect(
isStalkerAuthFailureResponse({
stalkerAuthFailure: 'Authorization failed.',
})
).toBe(true);
});
it('does not accept arbitrary objects as markers', () => {
expect(isStalkerAuthFailureResponse({ stalkerAuthFailure: 'nope' })).toBe(
false
);
expect(extractStalkerAuthFailureBody({})).toBeNull();
});
});
describe('extractStalkerAuthFailureBody', () => {
it('extracts from the raw PWA payload string', () => {
expect(extractStalkerAuthFailureBody('Authorization failed. 12')).toBe(
'Authorization failed.'
);
});
it('extracts from a {js: body} envelope', () => {
expect(
extractStalkerAuthFailureBody({ js: 'Authorization failed. 75' })
).toBe('Authorization failed.');
});
it('reports no canonical body for the structured panel wording', () => {
// Still an auth failure — just not one of the middleware's bodies.
const response = { js: { error: 'Invalid token' } };
expect(extractStalkerAuthFailureBody(response)).toBeNull();
expect(isStalkerAuthFailureResponse(response)).toBe(true);
});
it('returns null for ordinary responses', () => {
expect(extractStalkerAuthFailureBody({ js: { data: [] } })).toBeNull();
expect(extractStalkerAuthFailureBody(undefined)).toBeNull();
});
});
describe('isStalkerAuthFailureMessage', () => {
it('accepts messages our own auth layer produces', () => {
expect(
@@ -128,17 +128,77 @@ export function isStalkerAuthFailureMessage(message: unknown): boolean {
}
/**
* Whether a portal response is an authorization failure in EITHER wire
* shape: the middleware's plain-text body, or the JSON envelope some panels
* answer instead. Classification and the lazy-repair trigger must use this,
* not the string-only primitive: a JSON-failing panel would otherwise be
* persisted as token-free and never repaired.
* Structured shape a transport returns in place of the raw plain-text body.
*
* Returned, never thrown: `ipcRenderer.invoke` strips custom properties from
* rejected values, so a classified body would not survive the trip to the
* renderer as an error. The Electron main process mints this because it is
* where the body arrives; the PWA proxy still delivers the raw string, and
* every consumer goes through the predicates below rather than caring which.
*/
export interface StalkerAuthFailureMarker {
stalkerAuthFailure: StalkerAuthFailureBody;
}
export function createStalkerAuthFailureMarker(
body: StalkerAuthFailureBody
): StalkerAuthFailureMarker {
return { stalkerAuthFailure: body };
}
function readAuthFailureMarker(
value: unknown
): StalkerAuthFailureBody | null {
if (typeof value !== 'object' || value === null) {
return null;
}
return classifyStalkerAuthFailureBody(
(value as StalkerAuthFailureMarker).stalkerAuthFailure
);
}
/**
* Extracts the canonical failure body from any shape an auth failure reaches
* a consumer in: the transport marker (Electron), the raw plain-text payload
* (PWA proxy), or a `{js: '<body>'}` envelope. Returns null for everything
* else, including the structured `{js: {error|msg}}` form — that one is a
* panel's own wording, not one of the middleware's three bodies, so it has
* no canonical body to report. Use `isStalkerAuthFailureResponse` when the
* question is merely "did authorization fail?".
*/
export function extractStalkerAuthFailureBody(
value: unknown
): StalkerAuthFailureBody | null {
return (
readAuthFailureMarker(value) ??
classifyStalkerAuthFailureBody(value) ??
classifyStalkerAuthFailureBody(
typeof value === 'object' && value !== null && 'js' in value
? (value as { js?: unknown }).js
: undefined
)
);
}
/**
* Whether a portal response is an authorization failure in ANY wire shape:
* the middleware's plain-text body, the transport marker minted from it, or
* the JSON envelope some panels answer instead. Classification and the
* lazy-repair trigger must use this, not the string-only primitive: a
* JSON-failing panel would otherwise be persisted as token-free and never
* repaired, and an Electron-classified body would stop triggering repair at
* all.
*/
export function isStalkerAuthFailureResponse(response: unknown): boolean {
if (isStalkerAuthFailureBody(response)) {
return true;
}
if (readAuthFailureMarker(response) !== null) {
return true;
}
if (
response === null ||
typeof response !== 'object' ||
@@ -157,9 +157,7 @@
} @else if (isStalkerCategoryFailed()) {
<app-workspace-context-error-view
[title]="'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR' | translate"
[description]="
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT' | translate
"
[description]="stalkerCategoryErrorDescription()"
[showActionButtons]="false"
/>
} @else {
@@ -17,7 +17,10 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
StalkerStore,
asStalkerPortalError,
} from '@iptvnator/portal/stalker/data-access';
import {
PortalCategorySortMode,
persistPortalCategorySortMode,
@@ -152,6 +155,28 @@ export class WorkspaceContextPanelComponent {
this.stalkerStore.isCategoryResourceLoading;
readonly isStalkerCategoryFailed =
this.stalkerStore.isCategoryResourceFailed;
/**
* When category loading failed because the portal refused the session,
* the portal's own explanation (msg/block_msg or the documented
* plain-text failure body) replaces the generic hint; a login-required
* refusal gets its own actionable text.
*/
readonly stalkerCategoryErrorDescription = computed(() => {
const portalError = asStalkerPortalError(
this.isStalkerCategoryFailed()
);
if (portalError?.portalText) {
return portalError.portalText;
}
if (portalError?.kind === 'login-required') {
return this.translate.instant(
'PORTALS.ERROR_VIEW.STALKER_LOGIN_REQUIRED'
);
}
return this.translate.instant(
'WORKSPACE.CONTEXT.LOAD_CATEGORIES_ERROR_HINT'
);
});
// Category count badges are only available for Stalker Live TV, where the
// full channel list is cached — VOD/series/radio still page lazily, so
// their per-category totals are unknown.
-1
View File
@@ -38,7 +38,6 @@ export const maxLinesBaseline = [
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts',
'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts',
'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts',
'libs/portal/stalker/data-access/src/lib/stalker-session.service.ts',
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts',
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts',
'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts',