diff --git a/.changes/stalker-auth-lifecycle.md b/.changes/stalker-auth-lifecycle.md new file mode 100644 index 000000000..3b62dc566 --- /dev/null +++ b/.changes/stalker-auth-lifecycle.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index a98df166d..a29af7553 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 84b64d985..056cc73a1 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -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 = { diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 07597d02b..15d236808 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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 = { + 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 { - 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.' ); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d384fc23e..d583836f4 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "حذف" diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index f686a3dab..961d20ad3 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "حذف" diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7e31c2f4f..97630f1e7 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Выдаліць" diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 39e022ce4..f4989975e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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" diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a2107398a..0f4ca122b 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Διαγραφή" diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 39110d498..a86135fd8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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" diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index dca10e3d1..c936ccc3e 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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" diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 7199e0e6f..9bc0421ef 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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" diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 363518a0f..ee09db2be 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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" diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 802e180a9..5212c6bfe 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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" diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2f3b08d41..49e89011e 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "削除" diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 59b734ff5..999697dee 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "삭제" diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 3927b34cc..0038dcc28 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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" diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 2f4d4e800..40670643a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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ń" diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index cbf5b10dc..376dcd436 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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" diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 685bf964c..955ecd3dc 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Удалить" diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 99718c5fb..ea99b075a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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" diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index bf74b355b..d69b1134d 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "删除" diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 866127200..ecd350563 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "刪除" diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index bbc78ac74..f21a6c360 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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: '' }` 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 diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html index e7eb3e63f..57beb00fe 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html @@ -101,16 +101,27 @@ 'HOME.STALKER_PORTAL.SIGNATURE_HINT' | translate }} - + + {{ + 'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate + }} + diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts index d966e25fd..b59a12839 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -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. diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 5a0b67222..730e19a86 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -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 = { + '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; diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 5d10d789b..34e27d5f4 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -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; @@ -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) + ); + 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) + ); + 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) + ); + 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({ diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 2d265e795..cdba1f989 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -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 { // 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 { + 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 diff --git a/libs/portal/stalker/data-access/src/index.ts b/libs/portal/stalker/data-access/src/index.ts index 1b5f1df69..0f3e80c80 100644 --- a/libs/portal/stalker/data-access/src/index.ts +++ b/libs/portal/stalker/data-access/src/index.ts @@ -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'; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts new file mode 100644 index 000000000..cc4c6654b --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts @@ -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; + + 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.
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.', + }); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts new file mode 100644 index 000000000..51981bfec --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts @@ -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 { + 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 { + // 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 + ) {} + + async performHandshake( + portalUrl: string, + macAddress: string, + identity: StalkerPortalIdentity = {}, + storedToken?: string + ): Promise { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + const prehash = await generatePrehash(macAddress); + + const params: Record = { + 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( + 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 { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + + // Build metrics JSON matching working app + const metrics: Record = { + 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 = { + 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( + 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 { + const normalizedIdentity = normalizeStalkerPortalIdentity(identity); + const params: Record = { + 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( + 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 { + 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 { + 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; + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts index 38471a5ca..7dfa44c85 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.spec.ts @@ -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 } ); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts index 0764d747a..723587159 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.service.ts @@ -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 { 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 { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts new file mode 100644 index 000000000..f09ea54fd --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.spec.ts @@ -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.
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(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts new file mode 100644 index 000000000..d457b834d --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-portal-error.ts @@ -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.
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; +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts b/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts new file mode 100644 index 000000000..15b7c2101 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-response-classification.ts @@ -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; + 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; +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts new file mode 100644 index 000000000..e020da8fb --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-session-store.ts @@ -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 + ) {} + + /** Reads a playlist row through the lazily-resolved persistence stack. */ + async readRow(playlistId: string): Promise { + 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 { + 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 { + 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); + } + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index bb4540b2a..a20a7cc5c 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -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.
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({ diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 746e95a6c..ca78b3493 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -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 { - 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 { - // 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 - >(); - private watchdogPlaylists = new Map(); - private watchdogInFlight = new Set(); - 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 { - 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 { - 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 = { - type: 'stb', - action: 'handshake', - token: '', - prehash, - JsHttpRequest: '1-xml', - }; - - try { - const response: StalkerHandshakeResponse = - await this.dataService.sendIpcEvent( - 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 { - const normalizedIdentity = normalizeStalkerPortalIdentity(identity); - - // Build metrics JSON matching working app - const metrics: Record = { - 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 = { - 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( - 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 { - const params: Record = { - type: 'stb', - action: 'do_auth', - login: '', - password: '', - JsHttpRequest: '1-xml', - }; - - try { - const response = - await this.dataService.sendIpcEvent( - 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 { + 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 { + 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; - - // 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( 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) diff --git a/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts b/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts new file mode 100644 index 000000000..75d335dd6 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-token-cache.ts @@ -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(); + + /** + * 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); + } + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts new file mode 100644 index 000000000..760dc488f --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.spec.ts @@ -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; + + 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 { + 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(); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts new file mode 100644 index 000000000..3e94a8708 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts @@ -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 + ) => Promise; + /** + * 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; + logger: ReturnType; +} + +interface WatchdogTimers { + timeslotTimeout?: ReturnType; + interval?: ReturnType; +} + +/** + * 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(); + /** Activation-time snapshot; only a fallback when the row cannot be read. */ + private readonly playlists = new Map(); + private readonly inFlight = new Set(); + /** Survives stop/start: the cadence is a per-portal fact, not session state. */ + private readonly timings = new Map(); + 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 { + 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 { + 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); +} diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 144d062a0..31df5e615 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -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', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 2b3031066..67605e037 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -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)); diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index 2721ce84e..f16e5a709 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -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 */ diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts index 4332d46e7..fd6940178 100644 --- a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.spec.ts @@ -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( diff --git a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts index eb2d44edc..39927e0db 100644 --- a/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts +++ b/libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts @@ -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: ''}` 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' || diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index 39eefd3e7..f8fedcd72 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -157,9 +157,7 @@ } @else if (isStalkerCategoryFailed()) { } @else { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index fc2927f01..102c124d0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -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. diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index bbdd99343..752a99ecd 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -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',