diff --git a/CLAUDE.md b/CLAUDE.md index 32868bb61..6f6c85c3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1016,6 +1016,12 @@ engine` (restart required) or - TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About - See `docs/architecture/tmdb-metadata-enrichment.md` +**Portal Account Info**: + +- Both portal types expose an account-info dialog through the same entry points: header playlist switcher (bottom section for the active playlist + per-row ⋮ menu), dashboard source card ⋮ menu, and the command palette. Gates use the shared predicates in `libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts`; `WorkspaceShellHeaderService.openAccountInfoFor()` picks the dialog by playlist type. +- Xtream: `AccountInfoComponent` (`libs/portal/xtream/feature/src/lib/account-info/`), queries `get_account_info` live. +- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes (full portals: handshake+`get_profile`; `portal.php`: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields). Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog"). + **Favorites and Recently Viewed**: - Per-playlist favorites and global favorites diff --git a/apps/stalker-mock-server/src/app/handlers/get-main-info.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-main-info.handler.ts index ab0d4a1f8..4f0e4793f 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-main-info.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-main-info.handler.ts @@ -2,22 +2,25 @@ import { Request, Response } from 'express'; /** * Stalker `account_info/get_main_info` — subscription facts for the - * account-info dialog. Field names deliberately mirror the loosely - * standardized panels in the wild (end_date as a date string, tariff_plan, - * numeric status where 1 = active). + * account-info dialog. Uses the nested `js.account_info` envelope that + * Ministra-style portals answer with (the shape `fetchStalkerExpireDate()` + * already consumes), plus flat aliases some panels emit alongside it. */ export function handleGetMainInfo(req: Request, res: Response): void { const expiry = new Date(); expiry.setDate(expiry.getDate() + 180); + const mac = (req.query['mac'] as string) || '00:1A:79:00:00:01'; res.json({ js: { - mac: (req.query['mac'] as string) || '00:1A:79:00:00:01', + mac, phone: '10042', - login: 'mockuser', - status: 1, - tariff_plan: 'Mock Premium 180', - end_date: expiry.toISOString().slice(0, 10), + account_info: { + login: 'mockuser', + status: 1, + tariff_plan: 'Mock Premium 180', + end_date: expiry.toISOString().slice(0, 10), + }, }, }); } diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts index 53dcdcf01..600e325be 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts @@ -115,7 +115,10 @@ describe('StalkerAccountInfoService', () => { expect.objectContaining({ url: portalPlaylist.portalUrl, macAddress: portalPlaylist.macAddress, - params: { type: 'account_info', action: 'get_main_info' }, + params: expect.objectContaining({ + type: 'account_info', + action: 'get_main_info', + }), }) ); expect(snapshot).toEqual({ @@ -128,6 +131,48 @@ describe('StalkerAccountInfoService', () => { }); }); + it('unwraps the nested js.account_info envelope used by Ministra-style portals', async () => { + // Same envelope fetchStalkerExpireDate() consumes in + // stalker-player-request.utils — nested facts must win over + // flat aliases. + dataService.sendIpcEvent.mockResolvedValue({ + js: { + mac: '00:1A:79:00:00:01', + tariff_plan: 'Flat Alias Plan', + account_info: { + login: 'nested-user', + expire_date: 1_790_000_000, + tariff_plan_name: 'Nested Premium', + status: '1', + }, + }, + }); + + const snapshot = await service.fetchAccountInfo(portalPlaylist); + + expect(snapshot).toEqual({ + login: 'nested-user', + expireDate: 1_790_000_000, + tariffPlanName: 'Nested Premium', + status: 1, + mac: '00:1A:79:00:00:01', + phone: undefined, + }); + }); + + it('sends the JsHttpRequest parameter legacy panels expect', async () => { + dataService.sendIpcEvent.mockResolvedValue({ js: {} }); + + await service.fetchAccountInfo(portalPlaylist); + + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + params: expect.objectContaining({ JsHttpRequest: '1-xml' }), + }) + ); + }); + it('returns null when get_main_info yields no usable facts', async () => { dataService.sendIpcEvent.mockResolvedValue({ js: {} }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts index a3eee8cf0..39e4217b4 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts @@ -24,19 +24,30 @@ export interface StalkerAccountSnapshot { phone?: string; } +interface StalkerMainInfoFields { + mac?: string; + phone?: string; + login?: string; + fname?: string; + status?: number | string; + tariff_plan?: string; + tariff_plan_name?: string; + end_date?: number | string; + expire_date?: number | string; + expire_billing_date?: number | string; +} + interface StalkerMainInfoResponse { - js?: { - mac?: string; - phone?: string; - login?: string; - fname?: string; - status?: number | string; - tariff_plan?: string; - tariff_plan_name?: string; - end_date?: number | string; - expire_date?: number | string; - expire_billing_date?: number | string; - } | null; + js?: + | (StalkerMainInfoFields & { + /** + * Ministra-style portals nest the account block — + * `fetchStalkerExpireDate()` in stalker-player-request.utils + * reads this exact envelope. Other panels answer flat. + */ + account_info?: StalkerMainInfoFields | null; + }) + | null; } /** @@ -101,14 +112,26 @@ export class StalkerAccountInfoService { stalkerSession: this.stalkerSession, }, playlist, - { type: 'account_info', action: 'get_main_info' } + { + type: 'account_info', + action: 'get_main_info', + JsHttpRequest: '1-xml', + } ); - const info = response?.js; - if (!info || typeof info !== 'object') { + const flat = response?.js; + if (!flat || typeof flat !== 'object') { return null; } + // Nested envelope wins field-by-field; flat top-level values remain + // as aliases for panels that answer without the account_info block. + const nested = + flat.account_info && typeof flat.account_info === 'object' + ? flat.account_info + : {}; + const info: StalkerMainInfoFields = { ...flat, ...nested }; + return normalizeSnapshot({ login: info.login || info.fname || undefined, expireDate: