From 6c065124edf810a302d3c5883e21ded6d2868527 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:58:03 +0200 Subject: [PATCH] feat(stalker): add account info dialog for Stalker portals (#1330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(stalker): add account info dialog for Stalker portals Xtream playlists have had an account-info dialog for a while; Stalker portals stored the same facts (login, expiry, tariff, status captured at import) as dead weight in the database and showed them nowhere. Add StalkerAccountInfoComponent mirroring the Xtream dialog's visual language: status pill, days-left/tariff/MAC hero stats, account and portal panels. Data is cached-first — the import-time snapshot renders instantly with a "Saved data" badge, then StalkerAccountInfoService refreshes it: full /stalker_portal/ installations re-run handshake+get_profile, portal.php panels are queried best-effort via account_info/get_main_info. A failed refresh keeps the cached snapshot; no data at all shows a retry-able error state. Entry points are unified behind shared portal-account predicates (isXtreamAccountPlaylist / isStalkerAccountPlaylist in shared/interfaces) so both portal types get the same set: header playlist switcher (bottom section + new per-row ⋮ Account info item), dashboard source card ⋮ menu, and the command palette (now visible on stalker routes with its own description). The header service picks the dialog by playlist type; the per-row path works for non-active playlists and skips the session-scoped stream counts. Also adds the missing top-level LOADING/RETRY i18n keys the Xtream dialog already referenced (they rendered as raw keys), a get_main_info handler in the stalker mock server, and STALKER.ACCOUNT_INFO translations for all 19 locales. Co-Authored-By: Claude Opus 5 * fix(stalker): unwrap nested js.account_info envelope in get_main_info Ministra-style portals nest the account block — fetchStalkerExpireDate() in stalker-player-request.utils already consumes exactly that shape, so the flat-only mapper silently discarded valid responses and legacy imports (which have no cached snapshot) got an empty account panel. Merge nested fields over flat aliases, send the JsHttpRequest parameter the existing get_main_info caller sends, switch the mock server to the nested envelope so the E2E covers the realistic shape, and document the account-info feature in CLAUDE.md (review feedback from Greptile and Codex on #1330). Co-Authored-By: Claude Opus 5 * test(stalker): pin account-info expiry fixture below the day boundary Math.round on the epoch could round up half a second, putting the fixture's expiry just past the 30-day mark so daysLeft ceil'd to 31 on CI. Floor keeps the interval strictly inside 30 days regardless of when within the second the spec runs. Co-Authored-By: Claude Opus 5 * refactor(stalker): address account-info review round two Three P2s from Codex on #1330: - Normalize the cached stalkerAccountInfo snapshot before rendering: the import path persists portal values verbatim, so expireDate can be a date string or milliseconds at runtime despite the declared number type. normalizeStoredStalkerAccountInfo() runs the same parsers as the fresh path. - Publish the re-auth token into StalkerSessionService's cache: strict portals invalidate the previous token per handshake, so the dialog's authenticate() would otherwise strand an active portal session on a dead token. - Extract the duplicated ~460-line account-dialog stylesheet into libs/ui/styles/_account-dialog.scss, shared by both dialogs with the provider accent injected via --account-dialog-accent; each consumer keeps only its accent and layout overrides. Co-Authored-By: Claude Opus 5 * fix(stalker): serialize account-profile refresh with session auth The dialog's direct authenticate() call bypassed the pendingAuth map ensureToken() uses, so a refresh could run a second handshake while a catalog or watchdog request was still authenticating. On strict portals each handshake invalidates the other's token, and the later setCachedToken() could publish an already-dead one. Move the refresh into StalkerSessionService.refreshAccountProfile(): it waits for any in-flight authentication, registers its own so later callers wait for it, and republishes the resulting token. A failed pending auth no longer aborts the refresh, and the pendingAuth entry is only cleared when it is still this call's. Co-Authored-By: Claude Opus 5 * fix(stalker): move pendingAuth cleanup out of the promise initializer TS2454 under the Angular compiler: the finally block referenced authPromise inside its own initializer, so every Electron/web production build failed even though jest and lint accepted it. Await the promise at the call site and retire the map entry there instead — same only-clear-our-own-entry semantics. Co-Authored-By: Claude Opus 5 * fix(stalker): harden account-info portal detection and expiry math Review round four (Codex P2s on #1330): - Fall back to the URL rule when isFullStalkerPortal is undefined: a playlist restored from an older backup carries no flag once the one-shot metadata migration has run, and it would then be sent down the unauthenticated legacy path and labelled a legacy panel. - Parse a bare YYYY-MM-DD expiry as a local calendar date. Date.parse reads it as UTC midnight, which renders as the previous day west of UTC and shifts the days-left boundary; timestamps carrying a time or offset keep standard parsing. - Decide expiry from the raw timestamp, not the rounded counter: an expiry that passed less than a day ago ceil's to 0/-0, so the hero stat claimed "0 days left" on a dead subscription. Co-Authored-By: Claude Opus 5 * fix(stalker): make account-profile refresh own the auth slot Review round five (Codex P2s on #1330): - Claim the pendingAuth slot in a loop and publish it before the first await. One settled promise releases every waiter at once, so a single pre-check let two queued refreshes both start handshakes that invalidate each other on strict portals. - Retire the cached token before the handshake: ensureToken() reads tokenCache before pendingAuth, so catalog and watchdog requests starting mid-handshake were handed a token this refresh was about to kill instead of queueing on the slot. - Render the portal type from the same resolver the fetch path uses, so a restored backup without an explicit flag is no longer labelled a legacy panel while authenticating as a full portal. Co-Authored-By: Claude Opus 5 * fix(stalker): retire only the token that actually failed auth A request dispatched with the previous token can see its authorization failure arrive after a profile refresh has already cached a fresh one. The retry path deleted the cache blindly, killing the fresh token and kicking off another handshake that in turn invalidated tokens of newer requests — cascading retries on strict portals. makeAuthenticatedRequest() now retires the cached token only while it still equals the token that failed; a late failure of a stale token leaves the refreshed token in place and the retry reuses it. Co-Authored-By: Claude Opus 5 * docs(stalker): distinguish the two no-data outcomes of the account dialog A portal that answers but publishes no account facts renders the ready-state "No account details" panel; only an unreachable portal without a cached snapshot enters the error state with retry. The doc conflated both as "error with retry" (review feedback on #1330). Co-Authored-By: Claude Opus 5 * fix(stalker): reject negative expiry sentinels before date parsing Portals encode unlimited/missing expiry as "-1" or "0"; the unsigned-digit check let "-1" fall through to Date.parse, which V8 reads as January 1, 2001 — an unlimited account rendered as expired. Signed numeric strings now take the numeric branch, whose non-positive guard already discards them. Co-Authored-By: Claude Opus 5 * fix(stalker): reject out-of-range calendar components in expiry dates The multi-argument Date constructor normalizes invalid components ('2026-00-00' becomes Nov 30, 2025), fabricating an expiry and countdown from a placeholder. Round-trip the parsed year/month/day and reject any date that does not survive unchanged. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .changes/stalker-account-info-dialog.md | 10 + CLAUDE.md | 6 + .../src/app/handlers/get-main-info.handler.ts | 26 + .../src/app/routes/dispatch.ts | 4 + apps/web-e2e/src/stalker.e2e.ts | 20 + apps/web/src/app/app.component.spec.ts | 1 + .../workspace-shell-actions.service.ts | 14 + apps/web/src/assets/i18n/ar.json | 29 +- apps/web/src/assets/i18n/ary.json | 29 +- apps/web/src/assets/i18n/by.json | 29 +- apps/web/src/assets/i18n/de.json | 29 +- apps/web/src/assets/i18n/el.json | 29 +- apps/web/src/assets/i18n/en.json | 29 +- apps/web/src/assets/i18n/es.json | 29 +- apps/web/src/assets/i18n/fr.json | 29 +- apps/web/src/assets/i18n/hu.json | 29 +- apps/web/src/assets/i18n/it.json | 29 +- apps/web/src/assets/i18n/ja.json | 29 +- apps/web/src/assets/i18n/ko.json | 29 +- apps/web/src/assets/i18n/nl.json | 29 +- apps/web/src/assets/i18n/pl.json | 29 +- apps/web/src/assets/i18n/pt.json | 29 +- apps/web/src/assets/i18n/ru.json | 29 +- apps/web/src/assets/i18n/tr.json | 29 +- apps/web/src/assets/i18n/zh.json | 29 +- apps/web/src/assets/i18n/zhtw.json | 29 +- docs/architecture/stalker-portal.md | 35 ++ .../playlist-switcher.component.html | 9 + .../playlist-switcher.component.spec.ts | 16 + .../playlist-switcher.component.ts | 16 +- libs/portal/stalker/data-access/src/index.ts | 1 + .../lib/stalker-account-info.service.spec.ts | 317 +++++++++++ .../src/lib/stalker-account-info.service.ts | 276 +++++++++ .../src/lib/stalker-session.service.spec.ts | 198 +++++++ .../src/lib/stalker-session.service.ts | 110 +++- libs/portal/stalker/feature/src/index.ts | 1 + .../stalker-account-info.component.html | 214 +++++++ .../stalker-account-info.component.scss | 21 + .../stalker-account-info.component.spec.ts | 259 +++++++++ .../stalker-account-info.component.ts | 287 ++++++++++ .../account-info/account-info.component.scss | 477 +--------------- libs/shared/interfaces/src/index.ts | 2 + .../src/lib/portal-account-playlist.utils.ts | 40 ++ ...lker-account-info-dialog-data.interface.ts | 11 + libs/ui/styles/_account-dialog.scss | 533 ++++++++++++++++++ .../src/lib/rails/dashboard-rail.utils.ts | 15 +- ...orkspace-dashboard-rails.component.spec.ts | 8 +- .../workspace-dashboard-rails.component.ts | 42 +- .../workspace-shell-header.component.html | 3 + .../workspace-shell-header.component.ts | 6 + .../workspace-shell-command-builders.ts | 8 +- .../workspace-shell-header.service.ts | 66 ++- .../services/workspace-shell.facade.spec.ts | 96 ++++ .../services/workspace-shell.facade.ts | 5 + .../workspace-shell.component.html | 1 + .../workspace-shell.component.spec.ts | 1 + .../util/src/lib/workspace-shell-actions.ts | 7 +- 57 files changed, 3169 insertions(+), 544 deletions(-) create mode 100644 .changes/stalker-account-info-dialog.md create mode 100644 apps/stalker-mock-server/src/app/handlers/get-main-info.handler.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.html create mode 100644 libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss create mode 100644 libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts create mode 100644 libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts create mode 100644 libs/shared/interfaces/src/lib/stalker-account-info-dialog-data.interface.ts create mode 100644 libs/ui/styles/_account-dialog.scss diff --git a/.changes/stalker-account-info-dialog.md b/.changes/stalker-account-info-dialog.md new file mode 100644 index 000000000..17fdbffb4 --- /dev/null +++ b/.changes/stalker-account-info-dialog.md @@ -0,0 +1,10 @@ +--- +type: feature +area: stalker +--- + +Stalker portals now have an Account info dialog — subscription status, +tariff plan, expiry date with a days-left counter, login, and portal +details. Open it from the header playlist menu, the dashboard source card, +or the command palette, same as for Xtream. If the portal is unreachable, +the data saved when the portal was added is shown instead. 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 new file mode 100644 index 000000000..4f0e4793f --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-main-info.handler.ts @@ -0,0 +1,26 @@ +import { Request, Response } from 'express'; + +/** + * Stalker `account_info/get_main_info` — subscription facts for the + * 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, + phone: '10042', + account_info: { + login: 'mockuser', + status: 1, + tariff_plan: 'Mock Premium 180', + end_date: expiry.toISOString().slice(0, 10), + }, + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/routes/dispatch.ts b/apps/stalker-mock-server/src/app/routes/dispatch.ts index bc42e9bee..41e99eed6 100644 --- a/apps/stalker-mock-server/src/app/routes/dispatch.ts +++ b/apps/stalker-mock-server/src/app/routes/dispatch.ts @@ -13,6 +13,7 @@ import { handleFavorites } from '../handlers/favorites.handler.js'; import { handleGetEpgInfo } from '../handlers/get-epg-info.handler.js'; import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js'; import { handleGetGenres } from '../handlers/get-genres.handler.js'; +import { handleGetMainInfo } from '../handlers/get-main-info.handler.js'; export interface DispatchOptions { /** @@ -63,6 +64,9 @@ export default function dispatchPortalAction( case 'do_auth': handleDoAuth(req, res); break; + case 'get_main_info': + handleGetMainInfo(req, res); + break; case 'get_categories': case 'get_genres_vod': case 'get_genres_itv': diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index f9c7ee264..bdda49a59 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -309,6 +309,26 @@ test('@stalker add a Stalker portal and see it in the playlist list', async ({ ).toBeVisible(); }); +test('@stalker account info dialog shows subscription facts from the portal', async ({ + page, +}) => { + await addStalkerPortal(page); + + // Open the header playlist switcher and its "Account info" entry for + // the active stalker portal. + await page.locator('.playlist-switcher-trigger').click(); + await page + .locator('.context-action-item', { hasText: 'Account info' }) + .click(); + + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('Account Information')).toBeVisible(); + // Facts served by the mock's account_info/get_main_info handler. + await expect(dialog.getByText('Mock Premium 180').first()).toBeVisible(); + await expect(dialog.getByText(DEFAULT_MAC).first()).toBeVisible(); +}); + test('@stalker VOD — categories load from mock server', async ({ page }) => { await addStalkerPortal(page); diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index 0aa298fe4..3bb625036 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -138,6 +138,7 @@ describe('AppComponent', () => { openGlobalRecent: jest.fn(), openGlobalSearch: jest.fn(), openAccountInfo: jest.fn(), + openStalkerAccountInfo: jest.fn(), }, }, ], diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index 14d2bbfb9..a110883a6 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -7,6 +7,7 @@ import { WorkspaceAccountInfoData, WorkspacePlaylistType, WorkspaceShellActions, + WorkspaceStalkerAccountInfoData, } from '@iptvnator/workspace/shell/util'; @Injectable({ providedIn: 'root' }) @@ -54,6 +55,19 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions { } ); } + + openStalkerAccountInfo(data: WorkspaceStalkerAccountInfoData): void { + void import('@iptvnator/portal/stalker/feature').then( + ({ StalkerAccountInfoComponent }) => { + this.dialog.open(StalkerAccountInfoComponent, { + width: '80%', + maxWidth: '1200px', + maxHeight: '90vh', + data, + }); + } + ); + } } export function provideWorkspaceShellActions(): Provider[] { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b14d45fa3..8dc608feb 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "الانتقال إلى قسم المسلسلات", "SWITCH_PLAYER_LABEL": "تبديل المشغل إلى {{name}}", "SWITCH_PLAYER_DESCRIPTION": "تعيين {{name}} كمشغل الفيديو النشط", - "SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}" + "SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker" }, "XTREAM_REFRESH_TITLE": "جارٍ تحديث قائمة التشغيل", "XTREAM_REFRESH_COLLECTING_DATA": "جارٍ حفظ بيانات مكتبتك...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "جارٍ التحميل", + "RETRY": "إعادة المحاولة", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "معلومات الحساب", + "ACCOUNT": "الحساب", + "PORTAL_INFO": "البوابة", + "LOGIN": "اسم المستخدم", + "STATUS": "الحالة", + "ACTIVE": "نشط", + "INACTIVE": "غير نشط", + "TARIFF_PLAN": "خطة الاشتراك", + "EXPIRES": "ينتهي في", + "DAYS_LEFT": "الأيام المتبقية", + "EXPIRED": "منتهي", + "MAC_ADDRESS": "عنوان MAC", + "PHONE": "الهاتف", + "PORTAL_URL": "رابط البوابة", + "PORTAL_TYPE_FULL": "بوابة Ministra", + "PORTAL_TYPE_LEGACY": "بوابة تقليدية (portal.php)", + "CACHED_BADGE": "بيانات محفوظة", + "REFRESH_FAILED": "تعذّر الوصول إلى البوابة — يتم عرض البيانات المحفوظة عند إضافة البوابة.", + "NO_ACCOUNT_DATA_TITLE": "لا توجد بيانات للحساب", + "NO_ACCOUNT_DATA_DESCRIPTION": "لا تنشر هذه البوابة معلومات الحساب لهذا الجهاز." + } } } diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index d7845a1b8..7f8a4dc63 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "اقفز لقسم السلاسل", "SWITCH_PLAYER_LABEL": "بدل المشغل ل{{name}}", "SWITCH_PLAYER_DESCRIPTION": "حدد {{name}} كمشغل الفيديو النشط", - "SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}" + "SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker" }, "XTREAM_REFRESH_TITLE": "كنحدّثو البلايليست", "XTREAM_REFRESH_COLLECTING_DATA": "كنحافظو على معطيات المكتبة ديالك...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "كيتشارجا", + "RETRY": "عاود المحاولة", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "معلومات الحساب", + "ACCOUNT": "الحساب", + "PORTAL_INFO": "البوابة", + "LOGIN": "سمية المستخدم", + "STATUS": "الحالة", + "ACTIVE": "خدام", + "INACTIVE": "ماشي خدام", + "TARIFF_PLAN": "خطة الاشتراك", + "EXPIRES": "كتسالي ف", + "DAYS_LEFT": "الأيام اللي بقاو", + "EXPIRED": "سالات", + "MAC_ADDRESS": "عنوان MAC", + "PHONE": "التيليفون", + "PORTAL_URL": "رابط البوابة", + "PORTAL_TYPE_FULL": "بوابة Ministra", + "PORTAL_TYPE_LEGACY": "بوابة قديمة (portal.php)", + "CACHED_BADGE": "بيانات محفوظة", + "REFRESH_FAILED": "ما قدرناش نوصلو للبوابة — كنعرضو البيانات اللي تسجلات ملي تزادت البوابة.", + "NO_ACCOUNT_DATA_TITLE": "ما كايناش بيانات الحساب", + "NO_ACCOUNT_DATA_DESCRIPTION": "هاد البوابة ما كتعطيش معلومات الحساب لهاد الجهاز." + } } } diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index d6636bc64..c700eb2ca 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Перайсці да раздзела серыялаў", "SWITCH_PLAYER_LABEL": "Пераключыць прайгравальнік на {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Усталяваць {{name}} як актыўны відэапрайгравальнік", - "SWITCH_PLAYER_FEEDBACK": "Прайгравальнік усталяваны: {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Прайгравальнік усталяваны: {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Паказаць даныя акаўнта Stalker-партала" }, "XTREAM_REFRESH_TITLE": "Абнаўленне плэйліста", "XTREAM_REFRESH_COLLECTING_DATA": "Захаванне даных вашай бібліятэкі...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Загрузка", + "RETRY": "Паўтарыць", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Інфармацыя пра акаўнт", + "ACCOUNT": "Акаўнт", + "PORTAL_INFO": "Партал", + "LOGIN": "Лагін", + "STATUS": "Статус", + "ACTIVE": "Актыўны", + "INACTIVE": "Неактыўны", + "TARIFF_PLAN": "Тарыфны план", + "EXPIRES": "Сканчаецца", + "DAYS_LEFT": "Засталося дзён", + "EXPIRED": "Скончыўся", + "MAC_ADDRESS": "MAC-адрас", + "PHONE": "Тэлефон", + "PORTAL_URL": "URL партала", + "PORTAL_TYPE_FULL": "Партал Ministra", + "PORTAL_TYPE_LEGACY": "Класічны партал (portal.php)", + "CACHED_BADGE": "Захаваныя даныя", + "REFRESH_FAILED": "Партал недаступны — паказаны даныя, захаваныя пры даданні партала.", + "NO_ACCOUNT_DATA_TITLE": "Няма даных пра акаўнт", + "NO_ACCOUNT_DATA_DESCRIPTION": "Гэты партал не дае інфармацыі пра акаўнт для гэтай прылады." + } } } diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 277f8fda0..7379c62aa 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Zum Serienbereich wechseln", "SWITCH_PLAYER_LABEL": "Player auf {{name}} wechseln", "SWITCH_PLAYER_DESCRIPTION": "{{name}} als aktiven Videoplayer festlegen", - "SWITCH_PLAYER_FEEDBACK": "Player auf {{name}} festgelegt" + "SWITCH_PLAYER_FEEDBACK": "Player auf {{name}} festgelegt", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Kontodaten des Stalker-Portals anzeigen" }, "XTREAM_REFRESH_TITLE": "Playlist wird aktualisiert", "XTREAM_REFRESH_COLLECTING_DATA": "Mediathekdaten werden gesichert...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Laden", + "RETRY": "Erneut versuchen", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Kontoinformationen", + "ACCOUNT": "Konto", + "PORTAL_INFO": "Portal", + "LOGIN": "Login", + "STATUS": "Status", + "ACTIVE": "Aktiv", + "INACTIVE": "Inaktiv", + "TARIFF_PLAN": "Tarif", + "EXPIRES": "Läuft ab", + "DAYS_LEFT": "Verbleibende Tage", + "EXPIRED": "Abgelaufen", + "MAC_ADDRESS": "MAC-Adresse", + "PHONE": "Telefon", + "PORTAL_URL": "Portal-URL", + "PORTAL_TYPE_FULL": "Ministra-Portal", + "PORTAL_TYPE_LEGACY": "Klassisches Portal (portal.php)", + "CACHED_BADGE": "Gespeicherte Daten", + "REFRESH_FAILED": "Das Portal ist nicht erreichbar — es werden die beim Hinzufügen gespeicherten Daten angezeigt.", + "NO_ACCOUNT_DATA_TITLE": "Keine Kontodaten", + "NO_ACCOUNT_DATA_DESCRIPTION": "Dieses Portal stellt für dieses Gerät keine Kontoinformationen bereit." + } } } diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dfa78a790..5d79a7ddd 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Μετάβαση στην ενότητα σειρών", "SWITCH_PLAYER_LABEL": "Εναλλαγή προγράμματος αναπαραγωγής σε {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Ορισμός του {{name}} ως ενεργό πρόγραμμα αναπαραγωγής βίντεο", - "SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Εμφάνιση στοιχείων λογαριασμού της πύλης Stalker" }, "XTREAM_REFRESH_TITLE": "Ανανέωση λίστας αναπαραγωγής", "XTREAM_REFRESH_COLLECTING_DATA": "Διατήρηση των δεδομένων της βιβλιοθήκης σας...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Φόρτωση", + "RETRY": "Επανάληψη", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Πληροφορίες λογαριασμού", + "ACCOUNT": "Λογαριασμός", + "PORTAL_INFO": "Πύλη", + "LOGIN": "Όνομα χρήστη", + "STATUS": "Κατάσταση", + "ACTIVE": "Ενεργός", + "INACTIVE": "Ανενεργός", + "TARIFF_PLAN": "Πρόγραμμα χρέωσης", + "EXPIRES": "Λήγει", + "DAYS_LEFT": "Ημέρες που απομένουν", + "EXPIRED": "Έληξε", + "MAC_ADDRESS": "Διεύθυνση MAC", + "PHONE": "Τηλέφωνο", + "PORTAL_URL": "URL πύλης", + "PORTAL_TYPE_FULL": "Πύλη Ministra", + "PORTAL_TYPE_LEGACY": "Κλασική πύλη (portal.php)", + "CACHED_BADGE": "Αποθηκευμένα δεδομένα", + "REFRESH_FAILED": "Η πύλη δεν είναι προσβάσιμη — εμφανίζονται τα δεδομένα που αποθηκεύτηκαν κατά την προσθήκη της.", + "NO_ACCOUNT_DATA_TITLE": "Δεν υπάρχουν στοιχεία λογαριασμού", + "NO_ACCOUNT_DATA_DESCRIPTION": "Αυτή η πύλη δεν δημοσιεύει πληροφορίες λογαριασμού για αυτήν τη συσκευή." + } } } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 7e3523182..d354d8a03 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Jump to series section", "SWITCH_PLAYER_LABEL": "Switch player to {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Set {{name}} as the active video player", - "SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Show Stalker portal account details" }, "XTREAM_REFRESH_TITLE": "Refreshing playlist", "XTREAM_REFRESH_COLLECTING_DATA": "Preserving your library data...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Loading", + "RETRY": "Retry", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Account Information", + "ACCOUNT": "Account", + "PORTAL_INFO": "Portal", + "LOGIN": "Login", + "STATUS": "Status", + "ACTIVE": "Active", + "INACTIVE": "Inactive", + "TARIFF_PLAN": "Tariff plan", + "EXPIRES": "Expires", + "DAYS_LEFT": "Days left", + "EXPIRED": "Expired", + "MAC_ADDRESS": "MAC address", + "PHONE": "Phone", + "PORTAL_URL": "Portal URL", + "PORTAL_TYPE_FULL": "Ministra portal", + "PORTAL_TYPE_LEGACY": "Legacy portal (portal.php)", + "CACHED_BADGE": "Saved data", + "REFRESH_FAILED": "The portal could not be reached — showing the data saved when the portal was added.", + "NO_ACCOUNT_DATA_TITLE": "No account details", + "NO_ACCOUNT_DATA_DESCRIPTION": "This portal does not publish account information for this device." + } } } diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index e446f7958..f1a6802fe 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Ir a la sección de series", "SWITCH_PLAYER_LABEL": "Cambiar reproductor a {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Establecer {{name}} como reproductor de vídeo activo", - "SWITCH_PLAYER_FEEDBACK": "Reproductor establecido a {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Reproductor establecido a {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar los datos de la cuenta del portal Stalker" }, "XTREAM_REFRESH_TITLE": "Actualizando lista de reproducción", "XTREAM_REFRESH_COLLECTING_DATA": "Conservando los datos de tu biblioteca...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Cargando", + "RETRY": "Reintentar", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Información de la cuenta", + "ACCOUNT": "Cuenta", + "PORTAL_INFO": "Portal", + "LOGIN": "Usuario", + "STATUS": "Estado", + "ACTIVE": "Activa", + "INACTIVE": "Inactiva", + "TARIFF_PLAN": "Plan de tarifa", + "EXPIRES": "Caduca", + "DAYS_LEFT": "Días restantes", + "EXPIRED": "Caducada", + "MAC_ADDRESS": "Dirección MAC", + "PHONE": "Teléfono", + "PORTAL_URL": "URL del portal", + "PORTAL_TYPE_FULL": "Portal Ministra", + "PORTAL_TYPE_LEGACY": "Portal clásico (portal.php)", + "CACHED_BADGE": "Datos guardados", + "REFRESH_FAILED": "No se pudo conectar con el portal — se muestran los datos guardados al añadirlo.", + "NO_ACCOUNT_DATA_TITLE": "Sin datos de la cuenta", + "NO_ACCOUNT_DATA_DESCRIPTION": "Este portal no publica información de la cuenta para este dispositivo." + } } } diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 486dab1f7..b74eb8086 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Aller à la section séries", "SWITCH_PLAYER_LABEL": "Basculer le lecteur sur {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Définir {{name}} comme lecteur vidéo actif", - "SWITCH_PLAYER_FEEDBACK": "Lecteur défini sur {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Lecteur défini sur {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Afficher les informations du compte du portail Stalker" }, "XTREAM_REFRESH_TITLE": "Actualisation de la playlist", "XTREAM_REFRESH_COLLECTING_DATA": "Préservation des données de votre bibliothèque...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Chargement", + "RETRY": "Réessayer", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Informations du compte", + "ACCOUNT": "Compte", + "PORTAL_INFO": "Portail", + "LOGIN": "Identifiant", + "STATUS": "Statut", + "ACTIVE": "Actif", + "INACTIVE": "Inactif", + "TARIFF_PLAN": "Forfait", + "EXPIRES": "Expire", + "DAYS_LEFT": "Jours restants", + "EXPIRED": "Expiré", + "MAC_ADDRESS": "Adresse MAC", + "PHONE": "Téléphone", + "PORTAL_URL": "URL du portail", + "PORTAL_TYPE_FULL": "Portail Ministra", + "PORTAL_TYPE_LEGACY": "Portail classique (portal.php)", + "CACHED_BADGE": "Données enregistrées", + "REFRESH_FAILED": "Le portail est injoignable — affichage des données enregistrées lors de son ajout.", + "NO_ACCOUNT_DATA_TITLE": "Aucune donnée de compte", + "NO_ACCOUNT_DATA_DESCRIPTION": "Ce portail ne publie pas d’informations de compte pour cet appareil." + } } } diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 0f26026a3..4f41acc5f 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Ugrás a sorozatokhoz", "SWITCH_PLAYER_LABEL": "Lejátszóváltás: {{name}}", "SWITCH_PLAYER_DESCRIPTION": "{{name}} beállítása aktív videólejátszóként", - "SWITCH_PLAYER_FEEDBACK": "Aktív lejátszó: {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Aktív lejátszó: {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "A Stalker portál fiókadatainak megjelenítése" }, "XTREAM_REFRESH_TITLE": "Lejátszási lista frissítése", "XTREAM_REFRESH_COLLECTING_DATA": "A médiatár adatainak megőrzése…", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Még nincs megtekintési előzmény", "NO_ITEMS_BODY": "Indítson el valamilyen tartalmat a médiatárból; ezután itt is megjelenik." } + }, + "LOADING": "Betöltés", + "RETRY": "Újra", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Fiókadatok", + "ACCOUNT": "Fiók", + "PORTAL_INFO": "Portál", + "LOGIN": "Felhasználónév", + "STATUS": "Állapot", + "ACTIVE": "Aktív", + "INACTIVE": "Inaktív", + "TARIFF_PLAN": "Díjcsomag", + "EXPIRES": "Lejárat", + "DAYS_LEFT": "Hátralévő napok", + "EXPIRED": "Lejárt", + "MAC_ADDRESS": "MAC-cím", + "PHONE": "Telefon", + "PORTAL_URL": "Portál URL", + "PORTAL_TYPE_FULL": "Ministra portál", + "PORTAL_TYPE_LEGACY": "Klasszikus portál (portal.php)", + "CACHED_BADGE": "Mentett adatok", + "REFRESH_FAILED": "A portál nem érhető el — a portál hozzáadásakor mentett adatok láthatók.", + "NO_ACCOUNT_DATA_TITLE": "Nincsenek fiókadatok", + "NO_ACCOUNT_DATA_DESCRIPTION": "Ez a portál nem tesz közzé fiókadatokat ehhez az eszközhöz." + } } } diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 585e5706f..8e728760f 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Vai alla sezione serie", "SWITCH_PLAYER_LABEL": "Passa al lettore {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Imposta {{name}} come lettore video attivo", - "SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostra i dati dell’account del portale Stalker" }, "XTREAM_REFRESH_TITLE": "Aggiornamento playlist", "XTREAM_REFRESH_COLLECTING_DATA": "Conservazione dei dati della libreria...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Caricamento", + "RETRY": "Riprova", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Informazioni account", + "ACCOUNT": "Account", + "PORTAL_INFO": "Portale", + "LOGIN": "Login", + "STATUS": "Stato", + "ACTIVE": "Attivo", + "INACTIVE": "Inattivo", + "TARIFF_PLAN": "Piano tariffario", + "EXPIRES": "Scade", + "DAYS_LEFT": "Giorni rimanenti", + "EXPIRED": "Scaduto", + "MAC_ADDRESS": "Indirizzo MAC", + "PHONE": "Telefono", + "PORTAL_URL": "URL del portale", + "PORTAL_TYPE_FULL": "Portale Ministra", + "PORTAL_TYPE_LEGACY": "Portale classico (portal.php)", + "CACHED_BADGE": "Dati salvati", + "REFRESH_FAILED": "Impossibile raggiungere il portale — vengono mostrati i dati salvati al momento dell’aggiunta.", + "NO_ACCOUNT_DATA_TITLE": "Nessun dato account", + "NO_ACCOUNT_DATA_DESCRIPTION": "Questo portale non pubblica informazioni sull’account per questo dispositivo." + } } } diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 9b557ba75..5e29290a7 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "シリーズセクションに移動", "SWITCH_PLAYER_LABEL": "プレーヤーを{{name}}に切り替え", "SWITCH_PLAYER_DESCRIPTION": "{{name}}をアクティブなビデオプレーヤーに設定", - "SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました" + "SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalkerポータルのアカウント情報を表示" }, "XTREAM_REFRESH_TITLE": "プレイリストを更新中", "XTREAM_REFRESH_COLLECTING_DATA": "ライブラリデータを保持しています...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "読み込み中", + "RETRY": "再試行", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "アカウント情報", + "ACCOUNT": "アカウント", + "PORTAL_INFO": "ポータル", + "LOGIN": "ログイン", + "STATUS": "ステータス", + "ACTIVE": "有効", + "INACTIVE": "無効", + "TARIFF_PLAN": "料金プラン", + "EXPIRES": "有効期限", + "DAYS_LEFT": "残り日数", + "EXPIRED": "期限切れ", + "MAC_ADDRESS": "MACアドレス", + "PHONE": "電話番号", + "PORTAL_URL": "ポータルURL", + "PORTAL_TYPE_FULL": "Ministraポータル", + "PORTAL_TYPE_LEGACY": "レガシーポータル(portal.php)", + "CACHED_BADGE": "保存済みデータ", + "REFRESH_FAILED": "ポータルに接続できません — ポータル追加時に保存されたデータを表示しています。", + "NO_ACCOUNT_DATA_TITLE": "アカウント情報なし", + "NO_ACCOUNT_DATA_DESCRIPTION": "このポータルは、このデバイスのアカウント情報を公開していません。" + } } } diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 9e4c7f322..ae9eb9cb9 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "시리즈 섹션으로 이동", "SWITCH_PLAYER_LABEL": "플레이어를 {{name}}(으)로 전환", "SWITCH_PLAYER_DESCRIPTION": "{{name}}을(를) 활성 비디오 플레이어로 설정", - "SWITCH_PLAYER_FEEDBACK": "플레이어가 {{name}}(으)로 설정되었습니다" + "SWITCH_PLAYER_FEEDBACK": "플레이어가 {{name}}(으)로 설정되었습니다", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker 포털 계정 정보 표시" }, "XTREAM_REFRESH_TITLE": "재생목록 새로 고침 중", "XTREAM_REFRESH_COLLECTING_DATA": "라이브러리 데이터를 보존하는 중...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "로드 중", + "RETRY": "다시 시도", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "계정 정보", + "ACCOUNT": "계정", + "PORTAL_INFO": "포털", + "LOGIN": "로그인", + "STATUS": "상태", + "ACTIVE": "활성", + "INACTIVE": "비활성", + "TARIFF_PLAN": "요금제", + "EXPIRES": "만료일", + "DAYS_LEFT": "남은 일수", + "EXPIRED": "만료됨", + "MAC_ADDRESS": "MAC 주소", + "PHONE": "전화번호", + "PORTAL_URL": "포털 URL", + "PORTAL_TYPE_FULL": "Ministra 포털", + "PORTAL_TYPE_LEGACY": "레거시 포털(portal.php)", + "CACHED_BADGE": "저장된 데이터", + "REFRESH_FAILED": "포털에 연결할 수 없습니다 — 포털 추가 시 저장된 데이터를 표시합니다.", + "NO_ACCOUNT_DATA_TITLE": "계정 정보 없음", + "NO_ACCOUNT_DATA_DESCRIPTION": "이 포털은 이 기기에 대한 계정 정보를 제공하지 않습니다." + } } } diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 58b3d7044..5031f2e9f 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Spring naar de seriessectie", "SWITCH_PLAYER_LABEL": "Speler wisselen naar {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Stel {{name}} in als de actieve videospeler", - "SWITCH_PLAYER_FEEDBACK": "Speler ingesteld op {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Speler ingesteld op {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Accountgegevens van het Stalker-portaal tonen" }, "XTREAM_REFRESH_TITLE": "Afspeellijst vernieuwen", "XTREAM_REFRESH_COLLECTING_DATA": "Bibliotheekgegevens worden bewaard...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Laden", + "RETRY": "Opnieuw proberen", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Accountinformatie", + "ACCOUNT": "Account", + "PORTAL_INFO": "Portaal", + "LOGIN": "Gebruikersnaam", + "STATUS": "Status", + "ACTIVE": "Actief", + "INACTIVE": "Inactief", + "TARIFF_PLAN": "Tariefplan", + "EXPIRES": "Verloopt", + "DAYS_LEFT": "Dagen resterend", + "EXPIRED": "Verlopen", + "MAC_ADDRESS": "MAC-adres", + "PHONE": "Telefoon", + "PORTAL_URL": "Portaal-URL", + "PORTAL_TYPE_FULL": "Ministra-portaal", + "PORTAL_TYPE_LEGACY": "Klassiek portaal (portal.php)", + "CACHED_BADGE": "Opgeslagen gegevens", + "REFRESH_FAILED": "Het portaal is onbereikbaar — de bij het toevoegen opgeslagen gegevens worden getoond.", + "NO_ACCOUNT_DATA_TITLE": "Geen accountgegevens", + "NO_ACCOUNT_DATA_DESCRIPTION": "Dit portaal publiceert geen accountinformatie voor dit apparaat." + } } } diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 47aa42d8d..5ba87e878 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Przejdź do sekcji seriali", "SWITCH_PLAYER_LABEL": "Przełącz odtwarzacz na {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Ustaw {{name}} jako aktywny odtwarzacz wideo", - "SWITCH_PLAYER_FEEDBACK": "Odtwarzacz ustawiony na {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Odtwarzacz ustawiony na {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Pokaż dane konta portalu Stalker" }, "XTREAM_REFRESH_TITLE": "Odświeżanie listy odtwarzania", "XTREAM_REFRESH_COLLECTING_DATA": "Zachowywanie danych biblioteki...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Ładowanie", + "RETRY": "Ponów", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Informacje o koncie", + "ACCOUNT": "Konto", + "PORTAL_INFO": "Portal", + "LOGIN": "Login", + "STATUS": "Status", + "ACTIVE": "Aktywne", + "INACTIVE": "Nieaktywne", + "TARIFF_PLAN": "Plan taryfowy", + "EXPIRES": "Wygasa", + "DAYS_LEFT": "Pozostało dni", + "EXPIRED": "Wygasło", + "MAC_ADDRESS": "Adres MAC", + "PHONE": "Telefon", + "PORTAL_URL": "Adres URL portalu", + "PORTAL_TYPE_FULL": "Portal Ministra", + "PORTAL_TYPE_LEGACY": "Klasyczny portal (portal.php)", + "CACHED_BADGE": "Zapisane dane", + "REFRESH_FAILED": "Portal jest niedostępny — wyświetlane są dane zapisane podczas dodawania portalu.", + "NO_ACCOUNT_DATA_TITLE": "Brak danych konta", + "NO_ACCOUNT_DATA_DESCRIPTION": "Ten portal nie udostępnia informacji o koncie dla tego urządzenia." + } } } diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 88f056e3f..4361aa9ed 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Ir para a seção de séries", "SWITCH_PLAYER_LABEL": "Alternar reprodutor para {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Definir {{name}} como reprodutor de vídeo ativo", - "SWITCH_PLAYER_FEEDBACK": "Reprodutor definido como {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Reprodutor definido como {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar dados da conta do portal Stalker" }, "XTREAM_REFRESH_TITLE": "Atualizando playlist", "XTREAM_REFRESH_COLLECTING_DATA": "Preservando os dados da sua biblioteca...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "A carregar", + "RETRY": "Tentar novamente", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Informações da conta", + "ACCOUNT": "Conta", + "PORTAL_INFO": "Portal", + "LOGIN": "Utilizador", + "STATUS": "Estado", + "ACTIVE": "Ativa", + "INACTIVE": "Inativa", + "TARIFF_PLAN": "Plano tarifário", + "EXPIRES": "Expira", + "DAYS_LEFT": "Dias restantes", + "EXPIRED": "Expirada", + "MAC_ADDRESS": "Endereço MAC", + "PHONE": "Telefone", + "PORTAL_URL": "URL do portal", + "PORTAL_TYPE_FULL": "Portal Ministra", + "PORTAL_TYPE_LEGACY": "Portal clássico (portal.php)", + "CACHED_BADGE": "Dados guardados", + "REFRESH_FAILED": "Não foi possível contactar o portal — a mostrar os dados guardados quando o portal foi adicionado.", + "NO_ACCOUNT_DATA_TITLE": "Sem dados da conta", + "NO_ACCOUNT_DATA_DESCRIPTION": "Este portal não publica informações da conta para este dispositivo." + } } } diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 14492bf55..884541a6e 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Перейти к разделу сериалов", "SWITCH_PLAYER_LABEL": "Переключить плеер на {{name}}", "SWITCH_PLAYER_DESCRIPTION": "Сделать {{name}} активным видеоплеером", - "SWITCH_PLAYER_FEEDBACK": "Плеер переключен на {{name}}" + "SWITCH_PLAYER_FEEDBACK": "Плеер переключен на {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Показать данные аккаунта Stalker-портала" }, "XTREAM_REFRESH_TITLE": "Обновление плейлиста", "XTREAM_REFRESH_COLLECTING_DATA": "Сохраняем данные вашей библиотеки...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Загрузка", + "RETRY": "Повторить", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Информация об аккаунте", + "ACCOUNT": "Аккаунт", + "PORTAL_INFO": "Портал", + "LOGIN": "Логин", + "STATUS": "Статус", + "ACTIVE": "Активен", + "INACTIVE": "Неактивен", + "TARIFF_PLAN": "Тарифный план", + "EXPIRES": "Истекает", + "DAYS_LEFT": "Осталось дней", + "EXPIRED": "Истёк", + "MAC_ADDRESS": "MAC-адрес", + "PHONE": "Телефон", + "PORTAL_URL": "URL портала", + "PORTAL_TYPE_FULL": "Портал Ministra", + "PORTAL_TYPE_LEGACY": "Классический портал (portal.php)", + "CACHED_BADGE": "Сохранённые данные", + "REFRESH_FAILED": "Портал недоступен — показаны данные, сохранённые при добавлении портала.", + "NO_ACCOUNT_DATA_TITLE": "Нет данных об аккаунте", + "NO_ACCOUNT_DATA_DESCRIPTION": "Этот портал не предоставляет информацию об аккаунте для этого устройства." + } } } diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index f1fcb6d6d..9b77c9023 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "Diziler bölümüne atla", "SWITCH_PLAYER_LABEL": "Oynatıcıyı {{name}} olarak değiştir", "SWITCH_PLAYER_DESCRIPTION": "{{name}} oynatıcısını etkin video oynatıcı olarak ayarla", - "SWITCH_PLAYER_FEEDBACK": "Oynatıcı {{name}} olarak ayarlandı" + "SWITCH_PLAYER_FEEDBACK": "Oynatıcı {{name}} olarak ayarlandı", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker portalı hesap bilgilerini göster" }, "XTREAM_REFRESH_TITLE": "Oynatma listesi yenileniyor", "XTREAM_REFRESH_COLLECTING_DATA": "Kitaplık verileriniz korunuyor...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "Yükleniyor", + "RETRY": "Yeniden dene", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "Hesap Bilgileri", + "ACCOUNT": "Hesap", + "PORTAL_INFO": "Portal", + "LOGIN": "Kullanıcı adı", + "STATUS": "Durum", + "ACTIVE": "Etkin", + "INACTIVE": "Devre dışı", + "TARIFF_PLAN": "Tarife planı", + "EXPIRES": "Bitiş tarihi", + "DAYS_LEFT": "Kalan gün", + "EXPIRED": "Süresi doldu", + "MAC_ADDRESS": "MAC adresi", + "PHONE": "Telefon", + "PORTAL_URL": "Portal URL’si", + "PORTAL_TYPE_FULL": "Ministra portalı", + "PORTAL_TYPE_LEGACY": "Klasik portal (portal.php)", + "CACHED_BADGE": "Kayıtlı veriler", + "REFRESH_FAILED": "Portala ulaşılamadı — portal eklenirken kaydedilen veriler gösteriliyor.", + "NO_ACCOUNT_DATA_TITLE": "Hesap verisi yok", + "NO_ACCOUNT_DATA_DESCRIPTION": "Bu portal, bu cihaz için hesap bilgisi yayınlamıyor." + } } } diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 121218bd1..f704117f6 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "跳转到剧集区", "SWITCH_PLAYER_LABEL": "切换播放器为 {{name}}", "SWITCH_PLAYER_DESCRIPTION": "将 {{name}} 设为当前视频播放器", - "SWITCH_PLAYER_FEEDBACK": "播放器已设为 {{name}}" + "SWITCH_PLAYER_FEEDBACK": "播放器已设为 {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "显示 Stalker 门户账户信息" }, "XTREAM_REFRESH_TITLE": "正在刷新播放列表", "XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒体库数据...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "加载中", + "RETRY": "重试", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "账户信息", + "ACCOUNT": "账户", + "PORTAL_INFO": "门户", + "LOGIN": "登录名", + "STATUS": "状态", + "ACTIVE": "有效", + "INACTIVE": "无效", + "TARIFF_PLAN": "资费套餐", + "EXPIRES": "到期时间", + "DAYS_LEFT": "剩余天数", + "EXPIRED": "已过期", + "MAC_ADDRESS": "MAC 地址", + "PHONE": "电话", + "PORTAL_URL": "门户 URL", + "PORTAL_TYPE_FULL": "Ministra 门户", + "PORTAL_TYPE_LEGACY": "传统门户(portal.php)", + "CACHED_BADGE": "已保存的数据", + "REFRESH_FAILED": "无法连接门户 — 正在显示添加门户时保存的数据。", + "NO_ACCOUNT_DATA_TITLE": "无账户数据", + "NO_ACCOUNT_DATA_DESCRIPTION": "该门户不为此设备提供账户信息。" + } } } diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 00bde5baa..c7afdbbfa 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1346,7 +1346,8 @@ "GO_TO_SERIES_DESCRIPTION": "跳至影集區段", "SWITCH_PLAYER_LABEL": "切換播放器至 {{name}}", "SWITCH_PLAYER_DESCRIPTION": "將 {{name}} 設為使用中的影片播放器", - "SWITCH_PLAYER_FEEDBACK": "播放器已設為 {{name}}" + "SWITCH_PLAYER_FEEDBACK": "播放器已設為 {{name}}", + "ACCOUNT_INFO_DESCRIPTION_STALKER": "顯示 Stalker 入口網站帳戶資訊" }, "XTREAM_REFRESH_TITLE": "正在重新整理播放清單", "XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒體庫資料...", @@ -1456,5 +1457,31 @@ "NO_ITEMS_TITLE": "Nothing watched yet", "NO_ITEMS_BODY": "Start watching anything from your library and it will show up here." } + }, + "LOADING": "載入中", + "RETRY": "重試", + "STALKER": { + "ACCOUNT_INFO": { + "TITLE": "帳戶資訊", + "ACCOUNT": "帳戶", + "PORTAL_INFO": "入口網站", + "LOGIN": "登入名稱", + "STATUS": "狀態", + "ACTIVE": "有效", + "INACTIVE": "無效", + "TARIFF_PLAN": "資費方案", + "EXPIRES": "到期日", + "DAYS_LEFT": "剩餘天數", + "EXPIRED": "已過期", + "MAC_ADDRESS": "MAC 位址", + "PHONE": "電話", + "PORTAL_URL": "入口網站 URL", + "PORTAL_TYPE_FULL": "Ministra 入口網站", + "PORTAL_TYPE_LEGACY": "傳統入口網站(portal.php)", + "CACHED_BADGE": "已儲存的資料", + "REFRESH_FAILED": "無法連線至入口網站 — 顯示新增入口網站時儲存的資料。", + "NO_ACCOUNT_DATA_TITLE": "沒有帳戶資料", + "NO_ACCOUNT_DATA_DESCRIPTION": "此入口網站不提供此裝置的帳戶資訊。" + } } } diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 933a41581..c12535110 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -521,6 +521,41 @@ Import rule: - a fresh handshake must happen after import for full-portal sessions; imported backups never trust a serialized token +## Account Info Dialog + +`StalkerAccountInfoComponent` +(`libs/portal/stalker/feature/src/lib/stalker-account-info/`) mirrors the +Xtream account-info dialog's visual language and shows subscription facts +for a portal: status, login, tariff plan, expiry date with a days-left +counter, MAC/phone, and portal details. + +Data flow (two sources, cached-first): + +- Cached: `Playlist.stalkerAccountInfo`, captured from `get_profile` at + import time for full `/stalker_portal/` installations. The dialog loads it + by playlist id (the meta row does not carry it) and renders instantly with + a "Saved data" badge. +- Fresh: `StalkerAccountInfoService` + (`libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts`). + Full portals re-run handshake + `get_profile`; `portal.php` panels are + queried with `account_info/get_main_info`, whose field set varies between + panels and is mapped best-effort (absent fields render nothing). A failed + refresh keeps the cached snapshot and flags it. The two no-data outcomes + differ: a portal that answers but publishes no account facts (and no + cached snapshot exists) renders the ready-state "No account details" + panel, while only an unreachable portal without a cached snapshot enters + the error state with retry. + +Entry points are shared with Xtream and gated on the shared predicates in +`libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts` +(`isXtreamAccountPlaylist` / `isStalkerAccountPlaylist`): the header playlist +switcher (bottom section for the active playlist and the per-row ⋮ menu), +the dashboard source card ⋮ menu, and the command palette. The +`WorkspaceShellHeaderService.openAccountInfoFor()` branch picks the dialog +by playlist type; `WORKSPACE_SHELL_ACTIONS.openStalkerAccountInfo()` lazy +loads the component. The stalker-mock-server implements `get_main_info` for +dev/E2E. + ## Remote Control Integration Stalker live remote control is implemented in: diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html index d662a4c60..102890e08 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html @@ -247,6 +247,15 @@ info {{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }} + @if (hasAccountInfo(playlist)) { + + } @if (canRefreshPlaylist(playlist)) { + + } @else if (loadState() === 'loading') { + + } @else { + + } + + + diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss new file mode 100644 index 000000000..ddabac486 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.scss @@ -0,0 +1,21 @@ +@use '../../../../../../ui/styles/account-dialog'; + +:host { + display: block; + color: var(--app-body-color, var(--mat-sys-on-surface)); + --account-dialog-accent: var( + --app-provider-stalker, + var(--mat-sys-primary) + ); +} + +// Stalker shows three hero stats (days left / tariff / MAC), not four. +.account-dialog__hero-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +@media (max-width: 720px) { + .account-dialog__hero-stats { + grid-template-columns: 1fr; + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts new file mode 100644 index 000000000..48d2187e7 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.spec.ts @@ -0,0 +1,259 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { of, throwError } from 'rxjs'; +import { StalkerAccountInfoService } from '@iptvnator/portal/stalker/data-access'; +import { PlaylistsService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerAccountInfoComponent } from './stalker-account-info.component'; + +describe('StalkerAccountInfoComponent', () => { + let fixture: ComponentFixture; + let component: StalkerAccountInfoComponent; + let accountInfoService: { fetchAccountInfo: jest.Mock }; + let playlistsService: { getPlaylist: jest.Mock }; + + const playlist = { + _id: 'stalker-1', + title: 'Living Room Portal', + count: 0, + importDate: '2026-04-05T10:00:00.000Z', + autoRefresh: false, + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.example/portal.php', + } as PlaylistMeta; + + const freshSnapshot = { + login: 'user-42', + // floor, not round: rounding the epoch up puts the expiry a few + // hundred ms past the 30-day mark and ceil() then yields 31. + expireDate: Math.floor(Date.now() / 1000) + 30 * 86_400, + tariffPlanName: 'Premium', + status: 1, + }; + + const cachedSnapshot = { + login: 'cached-user', + expireDate: Math.floor(Date.now() / 1000) + 3 * 86_400, + tariffPlanName: 'Basic', + status: 1, + }; + + async function createComponent(): Promise { + await TestBed.configureTestingModule({ + imports: [ + StalkerAccountInfoComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: { playlist } }, + { + provide: StalkerAccountInfoService, + useValue: accountInfoService, + }, + { provide: PlaylistsService, useValue: playlistsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(StalkerAccountInfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + // The constructor load chains several awaits (cache read, portal + // fetch); a macrotask hop drains the remaining microtask chain. + await new Promise((resolve) => setTimeout(resolve)); + fixture.detectChanges(); + } + + beforeEach(() => { + accountInfoService = { + fetchAccountInfo: jest.fn().mockResolvedValue(freshSnapshot), + }; + playlistsService = { + getPlaylist: jest.fn().mockReturnValue(of(null)), + }; + }); + + it('shows the fresh snapshot when the portal answers', async () => { + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.snapshot()).toEqual(freshSnapshot); + expect(component.snapshotSource()).toBe('fresh'); + expect(component.isCachedSnapshot()).toBe(false); + expect(component.statusKind()).toBe('active'); + expect(component.daysLeft()).toBe(30); + expect(component.expiresSoon()).toBe(false); + }); + + it('falls back to the cached import snapshot when the refresh fails', async () => { + accountInfoService.fetchAccountInfo.mockRejectedValue( + new Error('offline') + ); + playlistsService.getPlaylist.mockReturnValue( + of({ stalkerAccountInfo: cachedSnapshot }) + ); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.snapshot()).toEqual(cachedSnapshot); + expect(component.snapshotSource()).toBe('cached'); + expect(component.refreshFailed()).toBe(true); + expect(component.expiresSoon()).toBe(true); + }); + + it('normalizes stringly-typed cached values persisted at import time', async () => { + accountInfoService.fetchAccountInfo.mockRejectedValue( + new Error('offline') + ); + playlistsService.getPlaylist.mockReturnValue( + of({ + stalkerAccountInfo: { + login: 'cached-user', + // Import persists portal values verbatim; a date string + // must not blow up daysLeft/expiry rendering. + expireDate: '2099-12-31', + status: '1', + }, + }) + ); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.statusKind()).toBe('active'); + expect(component.expiryDate()?.getFullYear()).toBe(2099); + expect(component.daysLeft()).toBeGreaterThan(0); + }); + + it('keeps the cached snapshot when the portal publishes nothing', async () => { + accountInfoService.fetchAccountInfo.mockResolvedValue(null); + playlistsService.getPlaylist.mockReturnValue( + of({ stalkerAccountInfo: cachedSnapshot }) + ); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.snapshot()).toEqual(cachedSnapshot); + expect(component.snapshotSource()).toBe('cached'); + expect(component.refreshFailed()).toBe(false); + }); + + it('shows the error state when there is no data at all', async () => { + accountInfoService.fetchAccountInfo.mockRejectedValue( + new Error('offline') + ); + + await createComponent(); + + expect(component.loadState()).toBe('error'); + expect(component.snapshot()).toBeNull(); + }); + + it('stays ready with an empty account panel when the portal is reachable but silent', async () => { + accountInfoService.fetchAccountInfo.mockResolvedValue(null); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.snapshot()).toBeNull(); + expect(component.accountDetails()).toEqual([]); + // Portal facts are always known, even without account data. + expect(component.portalDetails().map((row) => row.value)).toEqual([ + playlist.portalUrl, + playlist.macAddress, + ]); + }); + + it('survives a broken cache read and still shows fresh data', async () => { + playlistsService.getPlaylist.mockReturnValue( + throwError(() => new Error('db locked')) + ); + + await createComponent(); + + expect(component.loadState()).toBe('ready'); + expect(component.snapshot()).toEqual(freshSnapshot); + expect(component.snapshotSource()).toBe('fresh'); + }); + + it('reports an expiry that passed hours ago as expired, not "0 days left"', async () => { + accountInfoService.fetchAccountInfo.mockResolvedValue({ + ...freshSnapshot, + expireDate: Math.floor(Date.now() / 1000) - 3600, + }); + + await createComponent(); + + // Math.ceil of a fraction of a day rounds up to 0/-0, so the + // counter must not decide expiry. + expect(component.daysLeft()).toBe(-0); + expect(component.hasExpired()).toBe(true); + expect(component.heroStats()[0].labelKey).toBe( + 'STALKER.ACCOUNT_INFO.EXPIRED' + ); + }); + + it('labels a restored full portal by URL when the flag is absent', async () => { + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ + StalkerAccountInfoComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + playlist: { + ...playlist, + portalUrl: + 'http://portal.example/stalker_portal/c/', + isFullStalkerPortal: undefined, + }, + }, + }, + { + provide: StalkerAccountInfoService, + useValue: accountInfoService, + }, + { provide: PlaylistsService, useValue: playlistsService }, + ], + }).compileComponents(); + + const restored = TestBed.createComponent(StalkerAccountInfoComponent); + restored.detectChanges(); + await restored.whenStable(); + + // Presentation must agree with the fetch path, which resolves the + // portal type from the URL when the flag is missing. + expect(restored.componentInstance.isFullPortal).toBe(true); + expect(restored.nativeElement.textContent).toContain( + 'STALKER.ACCOUNT_INFO.PORTAL_TYPE_FULL' + ); + expect(restored.nativeElement.textContent).not.toContain( + 'STALKER.ACCOUNT_INFO.PORTAL_TYPE_LEGACY' + ); + }); + + it('renders no status pill for unknown status values', async () => { + accountInfoService.fetchAccountInfo.mockResolvedValue({ + ...freshSnapshot, + status: 7, + }); + + await createComponent(); + + expect(component.statusKind()).toBeNull(); + expect( + component + .accountDetails() + .find((row) => row.labelKey === 'STALKER.ACCOUNT_INFO.STATUS') + ).toBeUndefined(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts new file mode 100644 index 000000000..3d914ae6d --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-account-info/stalker-account-info.component.ts @@ -0,0 +1,287 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { firstValueFrom } from 'rxjs'; +import { + isFullStalkerPortalPlaylist, + normalizeStoredStalkerAccountInfo, + StalkerAccountInfoService, + StalkerAccountSnapshot, +} from '@iptvnator/portal/stalker/data-access'; +import { createLogger } from '@iptvnator/portal/shared/util'; +import { PlaylistsService } from '@iptvnator/services'; +import { + PlaylistMeta, + type StalkerAccountInfoDialogData, +} from '@iptvnator/shared/interfaces'; + +type AccountLoadState = 'loading' | 'ready' | 'error'; + +/** Where the currently displayed snapshot came from. */ +type SnapshotSource = 'fresh' | 'cached'; + +interface AccountDetailRow { + labelKey: string; + value: string; + mono?: boolean; + tone?: 'positive' | 'warning'; +} + +interface AccountStat { + icon: string; + labelKey: string; + value: string; + warning?: boolean; +} + +const DAY_IN_MS = 86_400_000; + +@Component({ + selector: 'app-stalker-account-info', + imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe], + templateUrl: './stalker-account-info.component.html', + styleUrl: './stalker-account-info.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StalkerAccountInfoComponent { + private readonly data = inject( + MAT_DIALOG_DATA + ); + private readonly accountInfoService = inject(StalkerAccountInfoService); + private readonly playlistsService = inject(PlaylistsService); + private readonly logger = createLogger('StalkerAccountInfo'); + + readonly playlist: PlaylistMeta = this.data.playlist; + /** + * Presentation must use the same resolution as the fetch path: a + * playlist restored from an older backup carries no explicit flag, + * and the raw field would label a real Ministra portal as legacy. + */ + readonly isFullPortal = isFullStalkerPortalPlaylist(this.playlist); + readonly loadState = signal('loading'); + readonly snapshot = signal(null); + readonly snapshotSource = signal(null); + readonly refreshFailed = signal(false); + readonly skeletonStats = [1, 2, 3]; + readonly skeletonPanels = [1, 2]; + + readonly playlistLabel = computed( + () => this.playlist.title || this.playlist.portalUrl || '-' + ); + readonly isCachedSnapshot = computed( + () => this.snapshotSource() === 'cached' + ); + /** + * 1 = active, 0 = inactive is the convention of the panels that report + * the field at all; anything else renders no pill instead of a guess. + */ + readonly statusKind = computed<'active' | 'inactive' | null>(() => { + const status = this.snapshot()?.status; + if (status === 1) return 'active'; + if (status === 0) return 'inactive'; + return null; + }); + readonly expiryDate = computed(() => { + const expireDate = this.snapshot()?.expireDate; + return expireDate ? new Date(expireDate * 1000) : null; + }); + readonly daysLeft = computed(() => { + const expiry = this.expiryDate(); + if (!expiry) { + return null; + } + return Math.ceil((expiry.getTime() - Date.now()) / DAY_IN_MS); + }); + /** + * Read from the raw timestamp, not daysLeft: an expiry that passed + * less than 24h ago rounds up to 0 (or -0), which is not negative, so + * the counter would claim "0 days left" on a dead subscription. + */ + readonly hasExpired = computed(() => { + const expiry = this.expiryDate(); + return expiry ? expiry.getTime() <= Date.now() : false; + }); + readonly expiresSoon = computed(() => { + const daysLeft = this.daysLeft(); + return daysLeft !== null && daysLeft <= 7; + }); + + readonly heroStats = computed(() => { + const stats: AccountStat[] = []; + const daysLeft = this.daysLeft(); + + if (daysLeft !== null) { + const expired = this.hasExpired(); + stats.push({ + icon: 'timer', + labelKey: expired + ? 'STALKER.ACCOUNT_INFO.EXPIRED' + : 'STALKER.ACCOUNT_INFO.DAYS_LEFT', + value: expired + ? this.formatDate(this.expiryDate()) + : String(daysLeft), + warning: this.expiresSoon(), + }); + } + + const tariff = this.snapshot()?.tariffPlanName; + if (tariff) { + stats.push({ + icon: 'loyalty', + labelKey: 'STALKER.ACCOUNT_INFO.TARIFF_PLAN', + value: tariff, + }); + } + + stats.push({ + icon: 'router', + labelKey: 'STALKER.ACCOUNT_INFO.MAC_ADDRESS', + value: this.snapshot()?.mac || this.playlist.macAddress || '-', + }); + + return stats; + }); + + readonly accountDetails = computed(() => { + const snapshot = this.snapshot(); + if (!snapshot) { + return []; + } + + const rows: AccountDetailRow[] = []; + const statusKind = this.statusKind(); + + if (statusKind) { + rows.push({ + labelKey: 'STALKER.ACCOUNT_INFO.STATUS', + value: + statusKind === 'active' + ? 'STALKER.ACCOUNT_INFO.ACTIVE' + : 'STALKER.ACCOUNT_INFO.INACTIVE', + tone: statusKind === 'active' ? 'positive' : 'warning', + }); + } + if (snapshot.login) { + rows.push({ + labelKey: 'STALKER.ACCOUNT_INFO.LOGIN', + value: snapshot.login, + mono: true, + }); + } + if (snapshot.tariffPlanName) { + rows.push({ + labelKey: 'STALKER.ACCOUNT_INFO.TARIFF_PLAN', + value: snapshot.tariffPlanName, + }); + } + if (this.expiryDate()) { + rows.push({ + labelKey: 'STALKER.ACCOUNT_INFO.EXPIRES', + value: this.formatDate(this.expiryDate()), + tone: this.expiresSoon() ? 'warning' : undefined, + }); + } + if (snapshot.phone) { + rows.push({ + labelKey: 'STALKER.ACCOUNT_INFO.PHONE', + value: snapshot.phone, + mono: true, + }); + } + + return rows; + }); + + readonly portalDetails = computed(() => [ + { + labelKey: 'STALKER.ACCOUNT_INFO.PORTAL_URL', + value: this.playlist.portalUrl || '-', + mono: true, + }, + { + labelKey: 'STALKER.ACCOUNT_INFO.MAC_ADDRESS', + value: this.playlist.macAddress || '-', + mono: true, + }, + ]); + + readonly translatedRowKeys: ReadonlySet = new Set([ + 'STALKER.ACCOUNT_INFO.ACTIVE', + 'STALKER.ACCOUNT_INFO.INACTIVE', + ]); + + constructor() { + void this.load(); + } + + async reload(): Promise { + await this.load(); + } + + private async load(): Promise { + this.refreshFailed.set(false); + if (!this.snapshot()) { + this.loadState.set('loading'); + } + + await this.loadCachedSnapshot(); + + try { + const fresh = await this.accountInfoService.fetchAccountInfo( + this.playlist + ); + if (fresh) { + this.snapshot.set(fresh); + this.snapshotSource.set('fresh'); + } + this.loadState.set('ready'); + } catch (error) { + this.logger.error('Failed to fetch account info', error); + if (this.snapshot()) { + // Keep showing the cached snapshot; just flag the refresh. + this.refreshFailed.set(true); + this.loadState.set('ready'); + } else { + this.loadState.set('error'); + } + } + } + + private async loadCachedSnapshot(): Promise { + if (this.snapshot()) { + return; + } + + try { + const playlist = await firstValueFrom( + this.playlistsService.getPlaylist(this.playlist._id) + ); + // The import path persists portal values verbatim, so the + // stored expiry may be a date string or milliseconds despite + // the declared type — normalize before rendering. + const cached = normalizeStoredStalkerAccountInfo( + playlist?.stalkerAccountInfo + ); + if (cached) { + this.snapshot.set(cached); + this.snapshotSource.set('cached'); + this.loadState.set('ready'); + } + } catch (error) { + // Cache miss is non-fatal — the fresh fetch decides the state. + this.logger.warn('Failed to read cached account info', error); + } + } + + private formatDate(date: Date | null): string { + return date ? date.toLocaleDateString() : '-'; + } +} diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss index f553b6fb3..e5c01a1cc 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss @@ -1,477 +1,10 @@ +@use '../../../../../../ui/styles/account-dialog'; + :host { display: block; color: var(--app-body-color, var(--mat-sys-on-surface)); -} - -// ─── Hero header ──────────────────────────────────────────────────────────── -.account-dialog__hero { - margin: 0; - padding: 20px 20px 18px; - display: grid; - gap: 18px; - background: - var(--app-accent-gradient), - var(--app-widget-header-bg, var(--mat-sys-surface-container-high)); - border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); -} - -.account-dialog__hero-copy { - display: grid; - gap: 10px; -} - -.account-dialog__eyebrow { - display: inline-flex; - align-items: center; - gap: 8px; - margin: 0; - color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-dialog__eyebrow-mark { - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--app-provider-xtream, var(--mat-sys-primary)); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 14%, transparent); -} - -.account-dialog__eyebrow-divider { - width: 3px; - height: 3px; - border-radius: 999px; - background: currentColor; - opacity: 0.5; -} - -.account-dialog__headline-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; -} - -.account-dialog__title { - margin: 0; - font-size: 1.3rem; - line-height: 1.2; - font-weight: 650; - letter-spacing: -0.02em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); -} - -.account-dialog__subtitle { - margin: 4px 0 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.88rem; -} - -// ─── Status pills ─────────────────────────────────────────────────────────── -.account-dialog__status-cluster { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 6px; -} - -.account-dialog__status-pill { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid var(--app-separator); - background: var(--mat-sys-surface-container); - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.76rem; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.account-dialog__status-pill--active { - border-color: color-mix(in srgb, #34d399 30%, transparent); - background: color-mix(in srgb, #34d399 12%, transparent); - color: #2db36f; -} - -.account-dialog__status-pill--trial { - border-color: color-mix(in srgb, #fbbf24 28%, transparent); - background: color-mix(in srgb, #f59e0b 12%, transparent); - color: #d18b09; -} - -// ─── Messages ─────────────────────────────────────────────────────────────── -.account-dialog__message, -.account-dialog__loading-copy { - margin: 0; - max-width: 60ch; - color: var(--app-body-color, var(--mat-sys-on-surface)); -} - -.account-dialog__message { - padding: 10px 14px; - border-radius: 10px; - border: 1px solid var(--app-separator); - background: var(--mat-sys-surface-container); - line-height: 1.5; - font-size: 0.88rem; -} - -// ─── Hero stat cards ──────────────────────────────────────────────────────── -.account-dialog__hero-stats { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; -} - -.account-stat { - display: grid; - gap: 6px; - padding: 12px 14px; - border-radius: 12px; - border: 1px solid var(--app-widget-border, transparent); - background: var(--app-widget-bg, var(--mat-sys-surface-container)); - box-shadow: var(--app-widget-shadow, none); - // Subtle inset highlight matching widget-shell pattern - outline: 1px solid rgba(255, 255, 255, 0.04); - outline-offset: -1px; -} - -.account-stat__icon { - width: 32px; - height: 32px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 10px; - background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 12%, transparent); - color: var(--app-provider-xtream, var(--mat-sys-primary)); - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } -} - -.account-stat__label { - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-stat__value { - font-size: 1.3rem; - font-weight: 650; - letter-spacing: -0.03em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); -} - -.account-stat__meter { - position: relative; - overflow: hidden; - height: 4px; - border-radius: 999px; - background: var(--app-separator); -} - -.account-stat__meter span { - display: block; - height: 100%; - border-radius: inherit; - background: var(--app-provider-xtream, var(--mat-sys-primary)); -} - -// ─── Content area ─────────────────────────────────────────────────────────── -.account-dialog__content { - margin: 0; - padding: 20px; - background: var(--app-widget-bg, var(--mat-sys-surface)); -} - -.account-dialog__grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} - -// ─── Detail panels ────────────────────────────────────────────────────────── -.account-panel { - display: grid; - gap: 14px; - padding: 16px; - border-radius: 16px; - border: 1px solid var(--app-widget-border, transparent); - background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); - box-shadow: var(--app-widget-shadow, none); - outline: 1px solid rgba(255, 255, 255, 0.04); - outline-offset: -1px; -} - -.account-panel__header { - display: grid; - gap: 4px; -} - -.account-panel__header h3 { - margin: 0; - font-size: 1rem; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--app-heading-color, var(--mat-sys-on-surface)); -} - -.account-panel__eyebrow { - margin: 0; - color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -// ─── Detail rows ──────────────────────────────────────────────────────────── -.account-detail-list { - display: grid; - gap: 0; - margin: 0; -} - -.account-detail-row { - display: grid; - grid-template-columns: minmax(120px, 140px) minmax(0, 1fr); - gap: 12px; - align-items: baseline; - padding: 10px 0; - border-bottom: 1px solid var(--app-separator); -} - -.account-detail-row:last-child { - border-bottom: 0; - padding-bottom: 0; -} - -.account-detail-row dt { - margin: 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-detail-row dd { - margin: 0; - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.9rem; - font-weight: 500; - word-break: break-word; -} - -.account-detail-row__value--mono { - font-family: 'JetBrains Mono', 'Roboto Mono', monospace; - font-size: 0.84rem; -} - -.account-detail-row__value--accent { - color: var(--app-provider-xtream, var(--mat-sys-primary)); -} - -.account-detail-row__value--positive { - color: #2db36f; -} - -.account-detail-row__value--warning { - color: #d18b09; -} - -// ─── Chip sections ────────────────────────────────────────────────────────── -.account-chip-section { - display: grid; - gap: 10px; -} - -.account-chip-section__label { - margin: 0; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.account-chip-group { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.account-chip { - display: inline-flex; - align-items: center; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 16%, transparent); - background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 8%, transparent); - color: var(--app-body-color, var(--mat-sys-on-surface)); - font-size: 0.8rem; - font-weight: 600; - text-transform: uppercase; -} - -.account-chip--mono { - font-family: 'JetBrains Mono', 'Roboto Mono', monospace; - text-transform: none; -} - -.account-chip--muted { - border-color: var(--app-separator); - background: var(--mat-sys-surface-container); - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); -} - -// ─── Error state ──────────────────────────────────────────────────────────── -.account-dialog__error-state { - display: grid; - justify-items: center; - gap: 12px; - padding: 36px 20px; - border-radius: 16px; - border: 1px dashed var(--app-separator); - background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); - text-align: center; -} - -.account-dialog__error-state h3, -.account-dialog__error-state p { - margin: 0; -} - -.account-dialog__error-state p { - max-width: 52ch; - color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); - line-height: 1.5; - font-size: 0.88rem; -} - -.account-dialog__error-icon { - width: 48px; - height: 48px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 14px; - background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent); - color: var(--mat-sys-error); -} - -// ─── Skeleton loading ─────────────────────────────────────────────────────── -.account-panel--loading { - gap: 12px; -} - -.account-skeleton { - position: relative; - overflow: hidden; - border-radius: 999px; - background: var(--app-separator); -} - -.account-skeleton::after { - content: ''; - position: absolute; - inset: 0; - transform: translateX(-100%); - background: linear-gradient( - 90deg, - transparent, - color-mix(in srgb, var(--app-heading-color, var(--mat-sys-on-surface)) 10%, transparent), - transparent + --account-dialog-accent: var( + --app-provider-xtream, + var(--mat-sys-primary) ); - animation: account-dialog-shimmer 1.4s infinite; -} - -.account-skeleton--stat { - min-height: 110px; - border-radius: 12px; -} - -.account-skeleton--heading { - width: 54%; - height: 20px; -} - -.account-skeleton--row { - width: 100%; - height: 16px; -} - -.account-skeleton--row.short { - width: 62%; -} - -// ─── Footer actions ───────────────────────────────────────────────────────── -.account-dialog__actions { - padding: 0 20px 20px; - display: flex; - justify-content: flex-end; - gap: 10px; - border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); - background: var(--app-widget-bg, var(--mat-sys-surface)); -} - -.account-dialog__close { - min-width: 100px; - border-radius: 999px; -} - -@keyframes account-dialog-shimmer { - 100% { - transform: translateX(100%); - } -} - -// ─── Responsive breakpoints ───────────────────────────────────────────────── -@media (max-width: 980px) { - .account-dialog__hero-stats, - .account-dialog__grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 720px) { - .account-dialog__hero, - .account-dialog__content, - .account-dialog__actions { - padding-left: 16px; - padding-right: 16px; - } - - .account-dialog__headline-row { - flex-direction: column; - align-items: flex-start; - } - - .account-dialog__status-cluster { - justify-content: flex-start; - } - - .account-dialog__hero-stats, - .account-dialog__grid { - grid-template-columns: 1fr; - } - - .account-detail-row { - grid-template-columns: 1fr; - gap: 4px; - } } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 597ab8443..5f9d2bc9e 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -25,6 +25,7 @@ export * from './lib/playback-position.interface'; export * from './lib/playlist-auto-update.interface'; export * from './lib/playlist-backup.interface'; export * from './lib/playlist-meta.type'; +export * from './lib/portal-account-playlist.utils'; export * from './lib/preload-performance-marker.interface'; export * from './lib/playlist-recently-viewed.interface'; export * from './lib/playlist-recently-viewed.utils'; @@ -44,6 +45,7 @@ export * from './lib/stream-format.enum'; export * from './lib/catalog-title-match.interface'; export * from './lib/theme.enum'; export * from './lib/season-marker.util'; +export * from './lib/stalker-account-info-dialog-data.interface'; export * from './lib/title-normalization.util'; export * from './lib/tmdb.interface'; export * from './lib/vod-source.interface'; diff --git a/libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts b/libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts new file mode 100644 index 000000000..5b7e631a2 --- /dev/null +++ b/libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts @@ -0,0 +1,40 @@ +import { PlaylistMeta } from './playlist-meta.type'; + +/** + * A playlist backed by an Xtream Codes account — the only kind the Xtream + * account-info dialog can query (`player_api.php` needs all three fields). + */ +export function isXtreamAccountPlaylist( + playlist: PlaylistMeta +): playlist is PlaylistMeta & { + serverUrl: string; + username: string; + password: string; +} { + return Boolean( + playlist.serverUrl && playlist.username && playlist.password + ); +} + +/** + * A playlist backed by a Stalker/Ministra portal. Portal URL + MAC address + * are what every Stalker request is keyed on, so together they identify an + * account the Stalker account-info dialog can describe. + */ +export function isStalkerAccountPlaylist( + playlist: PlaylistMeta +): playlist is PlaylistMeta & { + portalUrl: string; + macAddress: string; +} { + return Boolean(playlist.portalUrl && playlist.macAddress); +} + +/** + * Any playlist for which an account-info dialog exists. + */ +export function isPortalAccountPlaylist(playlist: PlaylistMeta): boolean { + return ( + isXtreamAccountPlaylist(playlist) || isStalkerAccountPlaylist(playlist) + ); +} diff --git a/libs/shared/interfaces/src/lib/stalker-account-info-dialog-data.interface.ts b/libs/shared/interfaces/src/lib/stalker-account-info-dialog-data.interface.ts new file mode 100644 index 000000000..a8841ba9b --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-account-info-dialog-data.interface.ts @@ -0,0 +1,11 @@ +import { PlaylistMeta } from './playlist-meta.type'; + +/** + * Dialog input for the Stalker account-info dialog. The meta row carries the + * portal URL, MAC address, and device identity needed for a fresh portal + * query; the cached `stalkerAccountInfo` snapshot lives only in the full + * playlist payload, so the dialog loads it by id. + */ +export interface StalkerAccountInfoDialogData { + playlist: PlaylistMeta; +} diff --git a/libs/ui/styles/_account-dialog.scss b/libs/ui/styles/_account-dialog.scss new file mode 100644 index 000000000..36a10e950 --- /dev/null +++ b/libs/ui/styles/_account-dialog.scss @@ -0,0 +1,533 @@ +// Shared account-info dialog stylesheet, used by both portal dialogs: +// - libs/portal/xtream/feature/src/lib/account-info/ +// - libs/portal/stalker/feature/src/lib/stalker-account-info/ +// +// The provider accent is injected via the `--account-dialog-accent` custom +// property, set on :host by each consumer. Provider-specific structural +// tweaks (e.g. the stats column count) stay in the consumer stylesheet. + +// ─── Hero header ──────────────────────────────────────────────────────────── +.account-dialog__hero { + margin: 0; + padding: 20px 20px 18px; + display: grid; + gap: 18px; + background: + var(--app-accent-gradient), + var(--app-widget-header-bg, var(--mat-sys-surface-container-high)); + border-bottom: 1px solid + var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); +} + +.account-dialog__hero-copy { + display: grid; + gap: 10px; +} + +.account-dialog__eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-dialog__eyebrow-mark { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--account-dialog-accent, var(--mat-sys-primary)); + box-shadow: 0 0 0 4px + color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 14%, + transparent + ); +} + +.account-dialog__eyebrow-divider { + width: 3px; + height: 3px; + border-radius: 999px; + background: currentColor; + opacity: 0.5; +} + +.account-dialog__headline-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.account-dialog__title { + margin: 0; + font-size: 1.3rem; + line-height: 1.2; + font-weight: 650; + letter-spacing: -0.02em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); +} + +.account-dialog__subtitle { + margin: 4px 0 0; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.88rem; +} + +// ─── Status pills ─────────────────────────────────────────────────────────── +.account-dialog__status-cluster { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.account-dialog__status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--app-separator); + background: var(--mat-sys-surface-container); + color: var(--app-body-color, var(--mat-sys-on-surface)); + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.account-dialog__status-pill--active { + border-color: color-mix(in srgb, #34d399 30%, transparent); + background: color-mix(in srgb, #34d399 12%, transparent); + color: #2db36f; +} + +.account-dialog__status-pill--trial, +.account-dialog__status-pill--cached { + border-color: color-mix(in srgb, #fbbf24 28%, transparent); + background: color-mix(in srgb, #f59e0b 12%, transparent); + color: #d18b09; +} + +// ─── Messages ─────────────────────────────────────────────────────────────── +.account-dialog__message, +.account-dialog__loading-copy { + margin: 0; + max-width: 60ch; + color: var(--app-body-color, var(--mat-sys-on-surface)); +} + +.account-dialog__message { + padding: 10px 14px; + border-radius: 10px; + border: 1px solid var(--app-separator); + background: var(--mat-sys-surface-container); + line-height: 1.5; + font-size: 0.88rem; +} + +// ─── Hero stat cards ──────────────────────────────────────────────────────── +.account-dialog__hero-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.account-stat { + display: grid; + gap: 6px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--app-widget-border, transparent); + background: var(--app-widget-bg, var(--mat-sys-surface-container)); + box-shadow: var(--app-widget-shadow, none); + // Subtle inset highlight matching widget-shell pattern + outline: 1px solid rgba(255, 255, 255, 0.04); + outline-offset: -1px; +} + +.account-stat__icon { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 12%, + transparent + ); + color: var(--account-dialog-accent, var(--mat-sys-primary)); + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.account-stat__label { + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-stat__value { + font-size: 1.3rem; + font-weight: 650; + letter-spacing: -0.03em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + overflow-wrap: anywhere; +} + +.account-stat__value--warning { + color: #d18b09; +} + +.account-stat__meter { + position: relative; + overflow: hidden; + height: 4px; + border-radius: 999px; + background: var(--app-separator); +} + +.account-stat__meter span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--account-dialog-accent, var(--mat-sys-primary)); +} + +// ─── Content area ─────────────────────────────────────────────────────────── +.account-dialog__content { + margin: 0; + padding: 20px; + background: var(--app-widget-bg, var(--mat-sys-surface)); +} + +.account-dialog__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +// ─── Detail panels ────────────────────────────────────────────────────────── +.account-panel { + display: grid; + gap: 14px; + padding: 16px; + border-radius: 16px; + border: 1px solid var(--app-widget-border, transparent); + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); + box-shadow: var(--app-widget-shadow, none); + outline: 1px solid rgba(255, 255, 255, 0.04); + outline-offset: -1px; + align-content: start; +} + +.account-panel__header { + display: grid; + gap: 4px; +} + +.account-panel__header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + overflow-wrap: anywhere; +} + +.account-panel__eyebrow { + margin: 0; + color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +// ─── Detail rows ──────────────────────────────────────────────────────────── +.account-detail-list { + display: grid; + gap: 0; + margin: 0; +} + +.account-detail-row { + display: grid; + grid-template-columns: minmax(120px, 140px) minmax(0, 1fr); + gap: 12px; + align-items: baseline; + padding: 10px 0; + border-bottom: 1px solid var(--app-separator); +} + +.account-detail-row:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.account-detail-row dt { + margin: 0; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-detail-row dd { + margin: 0; + color: var(--app-body-color, var(--mat-sys-on-surface)); + font-size: 0.9rem; + font-weight: 500; + word-break: break-word; +} + +.account-detail-row__value--mono { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + font-size: 0.84rem; +} + +.account-detail-row__value--accent { + color: var(--account-dialog-accent, var(--mat-sys-primary)); +} + +.account-detail-row__value--positive { + color: #2db36f; +} + +.account-detail-row__value--warning { + color: #d18b09; +} + +// ─── Chip sections ────────────────────────────────────────────────────────── +.account-chip-section { + display: grid; + gap: 10px; +} + +.account-chip-section__label { + margin: 0; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.account-chip-group { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.account-chip { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid + color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 16%, + transparent + ); + background: color-mix( + in srgb, + var(--account-dialog-accent, var(--mat-sys-primary)) 8%, + transparent + ); + color: var(--app-body-color, var(--mat-sys-on-surface)); + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; +} + +.account-chip--mono { + font-family: 'JetBrains Mono', 'Roboto Mono', monospace; + text-transform: none; +} + +.account-chip--muted { + border-color: var(--app-separator); + background: var(--mat-sys-surface-container); + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); +} + +// ─── Empty account block ──────────────────────────────────────────────────── +.account-dialog__empty-copy { + display: grid; + gap: 6px; + + h4 { + margin: 0; + font-size: 0.92rem; + font-weight: 600; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + } + + p { + margin: 0; + max-width: 46ch; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.85rem; + line-height: 1.5; + } +} + +// ─── Error state ──────────────────────────────────────────────────────────── +.account-dialog__error-state { + display: grid; + justify-items: center; + gap: 12px; + padding: 36px 20px; + border-radius: 16px; + border: 1px dashed var(--app-separator); + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); + text-align: center; +} + +.account-dialog__error-state h3, +.account-dialog__error-state p { + margin: 0; +} + +.account-dialog__error-state p { + max-width: 52ch; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + line-height: 1.5; + font-size: 0.88rem; +} + +.account-dialog__error-icon { + width: 48px; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent); + color: var(--mat-sys-error); +} + +// ─── Skeleton loading ─────────────────────────────────────────────────────── +.account-panel--loading { + gap: 12px; +} + +.account-skeleton { + position: relative; + overflow: hidden; + border-radius: 999px; + background: var(--app-separator); +} + +.account-skeleton::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + color-mix( + in srgb, + var(--app-heading-color, var(--mat-sys-on-surface)) 10%, + transparent + ), + transparent + ); + animation: account-dialog-shimmer 1.4s infinite; +} + +.account-skeleton--stat { + min-height: 110px; + border-radius: 12px; +} + +.account-skeleton--heading { + width: 54%; + height: 20px; +} + +.account-skeleton--row { + width: 100%; + height: 16px; +} + +.account-skeleton--row.short { + width: 62%; +} + +// ─── Footer actions ───────────────────────────────────────────────────────── +.account-dialog__actions { + padding: 0 20px 20px; + display: flex; + justify-content: flex-end; + gap: 10px; + border-top: 1px solid + var(--app-widget-header-border, rgba(255, 255, 255, 0.05)); + background: var(--app-widget-bg, var(--mat-sys-surface)); +} + +.account-dialog__close { + min-width: 100px; + border-radius: 999px; +} + +@keyframes account-dialog-shimmer { + 100% { + transform: translateX(100%); + } +} + +// ─── Responsive breakpoints ───────────────────────────────────────────────── +@media (max-width: 980px) { + .account-dialog__hero-stats, + .account-dialog__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .account-dialog__hero, + .account-dialog__content, + .account-dialog__actions { + padding-left: 16px; + padding-right: 16px; + } + + .account-dialog__headline-row { + flex-direction: column; + align-items: flex-start; + } + + .account-dialog__status-cluster { + justify-content: flex-start; + } + + .account-dialog__hero-stats, + .account-dialog__grid { + grid-template-columns: 1fr; + } + + .account-detail-row { + grid-template-columns: 1fr; + gap: 4px; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts index 767946b7d..f84ec2dee 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts @@ -1,4 +1,5 @@ import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { isPortalAccountPlaylist } from '@iptvnator/shared/interfaces'; import { buildCollectionViewState, COLLECTION_VIEW_STATE_KEY, @@ -48,7 +49,7 @@ export function buildDashboardSourceActions( labelKey: 'HOME.PLAYLISTS.SHOW_DETAILS', }); - if (isXtreamAccountPlaylist(playlist)) { + if (isPortalAccountPlaylist(playlist)) { actions.push({ id: 'account-info', icon: 'person', @@ -67,18 +68,6 @@ export function buildDashboardSourceActions( return actions; } -export function isXtreamAccountPlaylist( - playlist: PlaylistMeta -): playlist is PlaylistMeta & { - serverUrl: string; - username: string; - password: string; -} { - return Boolean( - playlist.serverUrl && playlist.username && playlist.password - ); -} - export function liveRailTitleKeyForSource( source: 'favorites' | 'recent' ): string { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index de9db5143..80ffce6cd 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -81,14 +81,18 @@ describe('buildDashboardSourceActions', () => { ]); }); - it('exposes info and remove for Stalker sources', () => { + it('exposes info, account, and remove for Stalker sources', () => { const playlist = { ...basePlaylist, macAddress: '00:1A:79:00:00:01', portalUrl: 'https://stalker.example.test', } as PlaylistMeta; - expect(actionIds(playlist, false)).toEqual(['playlist-info', 'remove']); + expect(actionIds(playlist, false)).toEqual([ + 'playlist-info', + 'account-info', + 'remove', + ]); }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 62fb50393..ee5f7230f 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -12,6 +12,8 @@ import { interval, of, startWith, switchMap } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; import { type EpgProgram, + isStalkerAccountPlaylist, + isXtreamAccountPlaylist, normalizeDashboardRailsSettings, } from '@iptvnator/shared/interfaces'; import { MatButtonModule } from '@angular/material/button'; @@ -73,7 +75,6 @@ import { buildDashboardCollectionViewState, buildDashboardRailSeeAllState, buildDashboardSourceActions, - isXtreamAccountPlaylist, liveRailTitleKeyForSource, RAIL_ITEM_LIMIT, shouldShowLiveFavoritesSkeleton, @@ -450,7 +451,7 @@ export class WorkspaceDashboardRailsComponent { this.dialog.open(PlaylistInfoComponent, { data: playlist }); break; case 'account-info': - this.openXtreamAccountInfo(playlist); + this.openAccountInfo(playlist); break; case 'remove': this.confirmRemovePlaylist(playlist); @@ -602,26 +603,29 @@ export class WorkspaceDashboardRailsComponent { return 'video_library'; } - private openXtreamAccountInfo(playlist: PlaylistMeta): void { - if (!isXtreamAccountPlaylist(playlist)) { + private openAccountInfo(playlist: PlaylistMeta): void { + if (isXtreamAccountPlaylist(playlist)) { + const title = + playlist.title || + playlist.filename || + this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'); + + this.shellActions.openAccountInfo({ + playlist: { + id: playlist._id, + name: title, + title, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }, + }); return; } - const title = - playlist.title || - playlist.filename || - this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'); - - this.shellActions.openAccountInfo({ - playlist: { - id: playlist._id, - name: title, - title, - serverUrl: playlist.serverUrl, - username: playlist.username, - password: playlist.password, - }, - }); + if (isStalkerAccountPlaylist(playlist)) { + this.shellActions.openStalkerAccountInfo({ playlist }); + } } private confirmRemovePlaylist(playlist: PlaylistMeta): void { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 1e055b323..32b3d22fe 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -34,6 +34,9 @@ [isRefreshingActivePlaylist]="isRefreshingPlaylist()" (playlistInfoRequested)="onPlaylistInfoRequested()" (accountInfoRequested)="onAccountInfoRequested()" + (accountInfoForPlaylistRequested)=" + onAccountInfoForPlaylistRequested($event) + " (addPlaylistRequested)="onAddPlaylistRequested()" (refreshPlaylistRequested)="onRefreshPlaylistRequested()" /> diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts index b7ee92f85..692510216 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts @@ -13,6 +13,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui'; import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shell-constants'; @Component({ @@ -105,6 +106,7 @@ export class WorkspaceShellHeaderComponent { readonly playlistInfoRequested = output(); readonly accountInfoRequested = output(); readonly contextDrawerToggleRequested = output(); + readonly accountInfoForPlaylistRequested = output(); focusSearchInput(options: { select?: boolean } = {}): void { const inputElement = this.searchInput()?.nativeElement; @@ -141,6 +143,10 @@ export class WorkspaceShellHeaderComponent { this.accountInfoRequested.emit(); } + onAccountInfoForPlaylistRequested(playlist: PlaylistMeta): void { + this.accountInfoForPlaylistRequested.emit(playlist); + } + onAddPlaylistRequested(): void { this.addPlaylistRequested.emit(); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts index 34582064a..c65eab3e4 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts @@ -128,9 +128,13 @@ export function getPlaylistCommandDefinitions( icon: 'account_circle', labelKey: 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_LABEL', descriptionKey: - 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION', + context.provider === 'stalker' + ? 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION_STALKER' + : 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION', priority: 40, - visible: context.provider === 'xtreams', + visible: + context.provider === 'xtreams' || + context.provider === 'stalker', run: () => ctx.actions.openAccountInfo(), }, ]; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts index ca2655dd6..51aecd51d 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts @@ -16,7 +16,11 @@ import { import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { PlaylistsService } from '@iptvnator/services'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + isStalkerAccountPlaylist, + isXtreamAccountPlaylist, + PlaylistMeta, +} from '@iptvnator/shared/interfaces'; import { WorkspaceAccountInfoData, WorkspacePortalContext, @@ -74,9 +78,14 @@ export class WorkspaceShellHeaderService { readonly canOpenPlaylistInfo = computed(() => Boolean(this.routeState.activePlaylist()) ); - readonly canOpenAccountInfo = computed(() => - Boolean(this.routeState.activePlaylist()?.serverUrl) - ); + readonly canOpenAccountInfo = computed(() => { + const playlist = this.routeState.activePlaylist(); + return Boolean( + playlist && + (isXtreamAccountPlaylist(playlist) || + isStalkerAccountPlaylist(playlist)) + ); + }); readonly canRefreshPlaylist = computed(() => this.playlistRefreshAction.canRefresh( this.routeState.activePlaylist() @@ -234,16 +243,51 @@ export class WorkspaceShellHeaderService { } openAccountInfo(): void { - if (!this.canOpenAccountInfo()) { + const playlist = this.routeState.activePlaylist(); + if (!playlist) { return; } - const data: WorkspaceAccountInfoData = { - vodStreamsCount: this.xtreamStore.vodStreams().length, - liveStreamsCount: this.xtreamStore.liveStreams().length, - seriesCount: this.xtreamStore.serialStreams().length, - }; - this.workspaceActions.openAccountInfo(data); + this.openAccountInfoFor(playlist); + } + + /** + * Opens the matching account-info dialog for any portal playlist — + * the active one (header button, command palette) or a specific row + * (playlist switcher item menu). + */ + openAccountInfoFor(playlist: PlaylistMeta): void { + if (isXtreamAccountPlaylist(playlist)) { + const isActivePlaylist = + this.routeState.activePlaylist()?._id === playlist._id; + const data: WorkspaceAccountInfoData = { + // Stream counts describe the loaded portal session, so they + // only apply when the dialog targets the active playlist. + ...(isActivePlaylist + ? { + vodStreamsCount: + this.xtreamStore.vodStreams().length, + liveStreamsCount: + this.xtreamStore.liveStreams().length, + seriesCount: this.xtreamStore.serialStreams().length, + } + : {}), + playlist: { + id: playlist._id, + name: playlist.title, + title: playlist.title, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }, + }; + this.workspaceActions.openAccountInfo(data); + return; + } + + if (isStalkerAccountPlaylist(playlist)) { + this.workspaceActions.openStalkerAccountInfo({ playlist }); + } } refreshCurrentPlaylist(): void { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 09acb4a83..3ad9b87b3 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -135,6 +135,7 @@ describe('WorkspaceShellFacade', () => { openGlobalSearch: jest.Mock; openGlobalRecent: jest.Mock; openAccountInfo: jest.Mock; + openStalkerAccountInfo: jest.Mock; }; let storeDispatch: jest.Mock; let activePlaylistSignal: ReturnType< @@ -209,6 +210,7 @@ describe('WorkspaceShellFacade', () => { openGlobalSearch: jest.fn(), openGlobalRecent: jest.fn(), openAccountInfo: jest.fn(), + openStalkerAccountInfo: jest.fn(), }; startupPreferences = { getFirstAvailableWorkspacePath: jest.fn((showDashboard: boolean) => @@ -893,6 +895,100 @@ describe('WorkspaceShellFacade', () => { ); }); + it('opens the Xtream account dialog with credentials and session counts for the active playlist', () => { + activePlaylistSignal.set({ + _id: 'pl-xtream', + title: 'Xtream One', + count: 42, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + serverUrl: 'https://provider.example.test', + username: 'demo', + password: 'secret', + } as PlaylistSignalMeta); + + facade.openAccountInfo(); + + expect(workspaceActions.openAccountInfo).toHaveBeenCalledWith({ + vodStreamsCount: 1, + liveStreamsCount: 1, + seriesCount: 1, + playlist: expect.objectContaining({ + id: 'pl-xtream', + serverUrl: 'https://provider.example.test', + username: 'demo', + password: 'secret', + }), + }); + expect(workspaceActions.openStalkerAccountInfo).not.toHaveBeenCalled(); + }); + + it('opens the Stalker account dialog for an active stalker playlist', () => { + const stalkerPlaylist = { + _id: 'pl-stalker', + title: 'Stalker Portal', + count: 0, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.example/portal.php', + } as PlaylistSignalMeta; + activePlaylistSignal.set(stalkerPlaylist); + + expect(facade.canOpenAccountInfo()).toBe(true); + + facade.openAccountInfo(); + + expect(workspaceActions.openStalkerAccountInfo).toHaveBeenCalledWith({ + playlist: stalkerPlaylist, + }); + expect(workspaceActions.openAccountInfo).not.toHaveBeenCalled(); + }); + + it('opens the account dialog for a non-active playlist without session counts', () => { + activePlaylistSignal.set(null); + const otherXtream = { + _id: 'pl-other', + title: 'Other Xtream', + count: 5, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + serverUrl: 'https://other.example.test', + username: 'user2', + password: 'pass2', + } as PlaylistSignalMeta; + + facade.openAccountInfoFor(otherXtream); + + expect(workspaceActions.openAccountInfo).toHaveBeenCalledWith({ + playlist: expect.objectContaining({ id: 'pl-other' }), + }); + }); + + it('offers the account-info command with a Stalker description on stalker routes', () => { + activePlaylistSignal.set({ + _id: 'pl-stalker', + title: 'Stalker Portal', + count: 0, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://portal.example/portal.php', + } as PlaylistSignalMeta); + facade.currentUrl.set('/workspace/stalker/pl-stalker/itv'); + + const accountCommand = facade + .commandPaletteCommands() + .find((command) => command.id === 'account-info'); + + expect(accountCommand).toBeDefined(); + // TranslateModule.forRoot() has no catalog, so the resolved + // description is the raw key — which is exactly what we assert. + expect(accountCommand?.description).toBe( + 'WORKSPACE.SHELL.COMMANDS.ACCOUNT_INFO_DESCRIPTION_STALKER' + ); + }); + it('places registered current-view commands ahead of globals on global favorites routes', () => { const viewCommands = TestBed.inject(WorkspaceViewCommandService); const clearCurrent = jest.fn(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index 83d705f4e..e68d22867 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -17,6 +17,7 @@ import { CommandBuilderActions, CommandBuilderContext, } from './helpers/workspace-shell-command-builders'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { isWorkspaceGlobalSearchablePlaylist } from './helpers/workspace-shell-searchable-playlists'; import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service'; import { WorkspaceShellHeaderService } from './workspace-shell-header.service'; @@ -225,6 +226,10 @@ export class WorkspaceShellFacade { this.header.openAccountInfo(); } + openAccountInfoFor(playlist: PlaylistMeta): void { + this.header.openAccountInfoFor(playlist); + } + refreshCurrentPlaylist(): void { this.header.refreshCurrentPlaylist(); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index ac7670f97..52f8c28f5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -62,6 +62,7 @@ (downloadsRequested)="facade.openDownloadsShortcut()" (playlistInfoRequested)="facade.openPlaylistInfo()" (accountInfoRequested)="facade.openAccountInfo()" + (accountInfoForPlaylistRequested)="facade.openAccountInfoFor($event)" />