mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e86e988e72
commit
582785ee8e
52 files changed
+2394
-68
No files matched your search
@@ -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.
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Stalker `account_info/get_main_info` — subscription facts for the
|
||||
* account-info dialog. Field names deliberately mirror the loosely
|
||||
* standardized panels in the wild (end_date as a date string, tariff_plan,
|
||||
* numeric status where 1 = active).
|
||||
*/
|
||||
export function handleGetMainInfo(req: Request, res: Response): void {
|
||||
const expiry = new Date();
|
||||
expiry.setDate(expiry.getDate() + 180);
|
||||
|
||||
res.json({
|
||||
js: {
|
||||
mac: (req.query['mac'] as string) || '00:1A:79:00:00:01',
|
||||
phone: '10042',
|
||||
login: 'mockuser',
|
||||
status: 1,
|
||||
tariff_plan: 'Mock Premium 180',
|
||||
end_date: expiry.toISOString().slice(0, 10),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ describe('AppComponent', () => {
|
||||
openGlobalRecent: jest.fn(),
|
||||
openGlobalSearch: jest.fn(),
|
||||
openAccountInfo: jest.fn(),
|
||||
openStalkerAccountInfo: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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": "لا تنشر هذه البوابة معلومات الحساب لهذا الجهاز."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "هاد البوابة ما كتعطيش معلومات الحساب لهاد الجهاز."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "Гэты партал не дае інфармацыі пра акаўнт для гэтай прылады."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "Αυτή η πύλη δεν δημοσιεύει πληροφορίες λογαριασμού για αυτήν τη συσκευή."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "このポータルは、このデバイスのアカウント情報を公開していません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "이 포털은 이 기기에 대한 계정 정보를 제공하지 않습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "Этот портал не предоставляет информацию об аккаунте для этого устройства."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "该门户不为此设备提供账户信息。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "此入口網站不提供此裝置的帳戶資訊。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -521,6 +521,38 @@ 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; with no data at all the
|
||||
dialog shows an 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:
|
||||
|
||||
@@ -247,6 +247,15 @@
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }}</span>
|
||||
</button>
|
||||
@if (hasAccountInfo(playlist)) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="requestAccountInfoFor(playlist, $event)"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SHELL.ACCOUNT_INFO' | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (canRefreshPlaylist(playlist)) {
|
||||
<button mat-menu-item (click)="refreshPlaylistFor(playlist, $event)">
|
||||
<mat-icon>sync</mat-icon>
|
||||
|
||||
@@ -327,6 +327,22 @@ describe('PlaylistSwitcherComponent', () => {
|
||||
expect(playlistInfoSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers per-playlist account info for portal playlists only', async () => {
|
||||
await createComponent();
|
||||
|
||||
const requestedPlaylists: PlaylistMeta[] = [];
|
||||
component.accountInfoForPlaylistRequested.subscribe((playlist) =>
|
||||
requestedPlaylists.push(playlist)
|
||||
);
|
||||
|
||||
expect(component.hasAccountInfo(m3uPlaylist)).toBe(false);
|
||||
expect(component.hasAccountInfo(stalkerPlaylist)).toBe(true);
|
||||
expect(component.hasAccountInfo(xtreamPlaylist)).toBe(true);
|
||||
|
||||
component.requestAccountInfoFor(stalkerPlaylist);
|
||||
expect(requestedPlaylists).toEqual([stalkerPlaylist]);
|
||||
});
|
||||
|
||||
it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => {
|
||||
await createComponent();
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ import {
|
||||
PortalStatus,
|
||||
PortalStatusService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
isPortalAccountPlaylist,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { startWith } from 'rxjs';
|
||||
import { PlaylistRefreshActionService } from '../playlist-refresh-action.service';
|
||||
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
|
||||
@@ -94,6 +97,7 @@ export class PlaylistSwitcherComponent {
|
||||
readonly playlistSelected = output<string>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
readonly accountInfoForPlaylistRequested = output<PlaylistMeta>();
|
||||
readonly addPlaylistRequested = output<void>();
|
||||
readonly refreshPlaylistRequested = output<void>();
|
||||
|
||||
@@ -288,6 +292,16 @@ export class PlaylistSwitcherComponent {
|
||||
this.dialog.open(PlaylistInfoComponent, { data: playlist });
|
||||
}
|
||||
|
||||
hasAccountInfo(playlist: PlaylistMeta): boolean {
|
||||
return isPortalAccountPlaylist(playlist);
|
||||
}
|
||||
|
||||
requestAccountInfoFor(playlist: PlaylistMeta, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.menuTrigger().closeMenu();
|
||||
this.accountInfoForPlaylistRequested.emit(playlist);
|
||||
}
|
||||
|
||||
refreshPlaylistFor(playlist: PlaylistMeta, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.menuTrigger().closeMenu();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './lib/models';
|
||||
export * from './lib/stores';
|
||||
export * from './lib/stalker-account-info.service';
|
||||
export * from './lib/stalker-content-types';
|
||||
export * from './lib/stalker-itv-cache.service';
|
||||
export * from './lib/stalker-live-playback.utils';
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
parseStalkerDate,
|
||||
StalkerAccountInfoService,
|
||||
} from './stalker-account-info.service';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
|
||||
describe('StalkerAccountInfoService', () => {
|
||||
let service: StalkerAccountInfoService;
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
let stalkerSession: {
|
||||
authenticate: jest.Mock;
|
||||
makeAuthenticatedRequest: jest.Mock;
|
||||
};
|
||||
|
||||
const portalPlaylist = {
|
||||
_id: 'stalker-1',
|
||||
title: 'Living Room',
|
||||
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 fullPortalPlaylist = {
|
||||
...portalPlaylist,
|
||||
_id: 'stalker-2',
|
||||
portalUrl: 'http://portal.example/stalker_portal/c/',
|
||||
isFullStalkerPortal: true,
|
||||
} as PlaylistMeta;
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = { sendIpcEvent: jest.fn() };
|
||||
stalkerSession = {
|
||||
authenticate: jest.fn(),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{ provide: StalkerSessionService, useValue: stalkerSession },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(StalkerAccountInfoService);
|
||||
});
|
||||
|
||||
it('returns null for playlists without portal credentials', async () => {
|
||||
await expect(
|
||||
service.fetchAccountInfo({
|
||||
...portalPlaylist,
|
||||
macAddress: undefined,
|
||||
} as PlaylistMeta)
|
||||
).resolves.toBeNull();
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(stalkerSession.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-authenticates full portals and maps the profile account block', async () => {
|
||||
stalkerSession.authenticate.mockResolvedValue({
|
||||
token: 'token-1',
|
||||
accountInfo: {
|
||||
login: 'user-77',
|
||||
expire_date: 1_790_000_000,
|
||||
tariff_plan_name: 'Premium',
|
||||
status: '1',
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await service.fetchAccountInfo(fullPortalPlaylist);
|
||||
|
||||
expect(stalkerSession.authenticate).toHaveBeenCalledWith(
|
||||
fullPortalPlaylist.portalUrl,
|
||||
fullPortalPlaylist.macAddress,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(snapshot).toEqual({
|
||||
login: 'user-77',
|
||||
expireDate: 1_790_000_000,
|
||||
tariffPlanName: 'Premium',
|
||||
status: 1,
|
||||
mac: undefined,
|
||||
phone: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the full-portal profile has no account block', async () => {
|
||||
stalkerSession.authenticate.mockResolvedValue({ token: 'token-1' });
|
||||
|
||||
await expect(
|
||||
service.fetchAccountInfo(fullPortalPlaylist)
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('queries get_main_info for portal.php panels and maps varying field names', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: {
|
||||
mac: '00:1A:79:00:00:01',
|
||||
phone: '10042',
|
||||
fname: 'Fallback Name',
|
||||
tariff_plan: 'Basic 30',
|
||||
end_date: '2026-10-01',
|
||||
status: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await service.fetchAccountInfo(portalPlaylist);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
STALKER_REQUEST,
|
||||
expect.objectContaining({
|
||||
url: portalPlaylist.portalUrl,
|
||||
macAddress: portalPlaylist.macAddress,
|
||||
params: { type: 'account_info', action: 'get_main_info' },
|
||||
})
|
||||
);
|
||||
expect(snapshot).toEqual({
|
||||
login: 'Fallback Name',
|
||||
expireDate: Math.round(Date.parse('2026-10-01') / 1000),
|
||||
tariffPlanName: 'Basic 30',
|
||||
status: 1,
|
||||
mac: '00:1A:79:00:00:01',
|
||||
phone: '10042',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when get_main_info yields no usable facts', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({ js: {} });
|
||||
|
||||
await expect(
|
||||
service.fetchAccountInfo(portalPlaylist)
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('propagates portal errors so the dialog can fall back to cached data', async () => {
|
||||
dataService.sendIpcEvent.mockRejectedValue(new Error('offline'));
|
||||
|
||||
await expect(
|
||||
service.fetchAccountInfo(portalPlaylist)
|
||||
).rejects.toThrow('offline');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStalkerDate', () => {
|
||||
it('accepts unix seconds, unix milliseconds, and date strings', () => {
|
||||
expect(parseStalkerDate(1_790_000_000)).toBe(1_790_000_000);
|
||||
expect(parseStalkerDate('1790000000')).toBe(1_790_000_000);
|
||||
expect(parseStalkerDate(1_790_000_000_000)).toBe(1_790_000_000);
|
||||
expect(parseStalkerDate('2026-10-01')).toBe(
|
||||
Math.round(Date.parse('2026-10-01') / 1000)
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty, zero, and placeholder dates', () => {
|
||||
expect(parseStalkerDate(undefined)).toBeUndefined();
|
||||
expect(parseStalkerDate(null)).toBeUndefined();
|
||||
expect(parseStalkerDate('')).toBeUndefined();
|
||||
expect(parseStalkerDate(0)).toBeUndefined();
|
||||
expect(parseStalkerDate('0000-00-00')).toBeUndefined();
|
||||
expect(parseStalkerDate('not a date')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { getStalkerPortalIdentityFromPlaylist } from './stalker-identity.utils';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
import {
|
||||
executeStalkerRequest,
|
||||
toStalkerSessionPlaylist,
|
||||
} from './stores/utils/stalker-request.utils';
|
||||
|
||||
/**
|
||||
* Normalized account facts for a Stalker portal. Shape matches the cached
|
||||
* `Playlist.stalkerAccountInfo` snapshot taken at import so both sources
|
||||
* render through the same view model; `mac`/`phone` only exist on the
|
||||
* fresh path.
|
||||
*/
|
||||
export interface StalkerAccountSnapshot {
|
||||
login?: string;
|
||||
/** Unix timestamp in seconds. */
|
||||
expireDate?: number;
|
||||
tariffPlanName?: string;
|
||||
status?: number;
|
||||
mac?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface StalkerMainInfoResponse {
|
||||
js?: {
|
||||
mac?: string;
|
||||
phone?: string;
|
||||
login?: string;
|
||||
fname?: string;
|
||||
status?: number | string;
|
||||
tariff_plan?: string;
|
||||
tariff_plan_name?: string;
|
||||
end_date?: number | string;
|
||||
expire_date?: number | string;
|
||||
expire_billing_date?: number | string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches fresh account information from a Stalker portal.
|
||||
*
|
||||
* Full `/stalker_portal/` installations expose the canonical account block
|
||||
* on `get_profile`, so those re-run the same handshake+profile pair the
|
||||
* import dialog used. Legacy `portal.php` panels have no profile call for
|
||||
* MAC-only accounts; for them the best available source is
|
||||
* `account_info/get_main_info`, whose field set varies wildly between
|
||||
* panels — everything is mapped best-effort and absent fields stay absent.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StalkerAccountInfoService {
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
|
||||
async fetchAccountInfo(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<StalkerAccountSnapshot | null> {
|
||||
if (!playlist.portalUrl || !playlist.macAddress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
return this.fetchViaProfile(playlist);
|
||||
}
|
||||
|
||||
return this.fetchViaMainInfo(playlist);
|
||||
}
|
||||
|
||||
private async fetchViaProfile(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<StalkerAccountSnapshot | null> {
|
||||
const identity = getStalkerPortalIdentityFromPlaylist(
|
||||
toStalkerSessionPlaylist(playlist)
|
||||
);
|
||||
const { accountInfo } = await this.stalkerSession.authenticate(
|
||||
playlist.portalUrl as string,
|
||||
playlist.macAddress as string,
|
||||
identity
|
||||
);
|
||||
|
||||
if (!accountInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSnapshot({
|
||||
login: accountInfo.login,
|
||||
expireDate: parseStalkerDate(accountInfo.expire_date),
|
||||
tariffPlanName: accountInfo.tariff_plan_name,
|
||||
status: parseStalkerNumber(accountInfo.status),
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchViaMainInfo(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<StalkerAccountSnapshot | null> {
|
||||
const response = await executeStalkerRequest<StalkerMainInfoResponse>(
|
||||
{
|
||||
dataService: this.dataService,
|
||||
stalkerSession: this.stalkerSession,
|
||||
},
|
||||
playlist,
|
||||
{ type: 'account_info', action: 'get_main_info' }
|
||||
);
|
||||
|
||||
const info = response?.js;
|
||||
if (!info || typeof info !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSnapshot({
|
||||
login: info.login || info.fname || undefined,
|
||||
expireDate:
|
||||
parseStalkerDate(info.expire_date) ??
|
||||
parseStalkerDate(info.end_date) ??
|
||||
parseStalkerDate(info.expire_billing_date),
|
||||
tariffPlanName:
|
||||
info.tariff_plan_name || info.tariff_plan || undefined,
|
||||
status: parseStalkerNumber(info.status),
|
||||
mac: info.mac || undefined,
|
||||
phone: info.phone || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSnapshot(
|
||||
snapshot: StalkerAccountSnapshot
|
||||
): StalkerAccountSnapshot | null {
|
||||
const hasAnyFact = Object.values(snapshot).some(
|
||||
(value) => value !== undefined
|
||||
);
|
||||
return hasAnyFact ? snapshot : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portals report dates as unix seconds, unix milliseconds, or date strings
|
||||
* ("2026-10-01", "0000-00-00"). Returns unix seconds, or undefined when the
|
||||
* value cannot be read as a real future-or-past date.
|
||||
*/
|
||||
export function parseStalkerDate(
|
||||
value: number | string | null | undefined
|
||||
): number | undefined {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || /^\d+$/.test(String(value).trim())) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
// Values past the year 3000 in seconds are actually milliseconds.
|
||||
return numeric > 32_503_680_000
|
||||
? Math.round(numeric / 1000)
|
||||
: Math.round(numeric);
|
||||
}
|
||||
|
||||
const parsed = Date.parse(String(value).trim());
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.round(parsed / 1000);
|
||||
}
|
||||
|
||||
function parseStalkerNumber(
|
||||
value: number | string | null | undefined
|
||||
): number | undefined {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './lib/stalker-account-info/stalker-account-info.component';
|
||||
export * from './lib/stalker-inline-detail/stalker-inline-detail.component';
|
||||
export * from './lib/stalker-collection-detail.component';
|
||||
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
<section mat-dialog-title class="account-dialog__hero">
|
||||
<div class="account-dialog__hero-copy">
|
||||
<div class="account-dialog__eyebrow">
|
||||
<span class="account-dialog__eyebrow-mark"></span>
|
||||
Stalker
|
||||
@if (playlist.isFullStalkerPortal) {
|
||||
<span class="account-dialog__eyebrow-divider"></span>
|
||||
{{ 'STALKER.ACCOUNT_INFO.PORTAL_TYPE_FULL' | translate }}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="account-dialog__headline-row">
|
||||
<div>
|
||||
<h2 class="account-dialog__title">
|
||||
{{ 'STALKER.ACCOUNT_INFO.TITLE' | translate }}
|
||||
</h2>
|
||||
<p class="account-dialog__subtitle">
|
||||
{{ playlistLabel() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="account-dialog__status-cluster">
|
||||
@if (statusKind(); as kind) {
|
||||
<span
|
||||
class="account-dialog__status-pill"
|
||||
[class.account-dialog__status-pill--active]="
|
||||
kind === 'active'
|
||||
"
|
||||
>
|
||||
@if (kind === 'active') {
|
||||
{{ 'STALKER.ACCOUNT_INFO.ACTIVE' | translate }}
|
||||
} @else {
|
||||
{{ 'STALKER.ACCOUNT_INFO.INACTIVE' | translate }}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@if (isCachedSnapshot() && loadState() === 'ready') {
|
||||
<span
|
||||
class="account-dialog__status-pill account-dialog__status-pill--cached"
|
||||
>
|
||||
{{ 'STALKER.ACCOUNT_INFO.CACHED_BADGE' | translate }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (refreshFailed()) {
|
||||
<div class="account-dialog__message">
|
||||
{{ 'STALKER.ACCOUNT_INFO.REFRESH_FAILED' | translate }}
|
||||
</div>
|
||||
} @else if (loadState() === 'loading') {
|
||||
<p class="account-dialog__loading-copy">
|
||||
{{ 'LOADING' | translate }}...
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="account-dialog__hero-stats">
|
||||
@if (loadState() === 'ready') {
|
||||
@for (stat of heroStats(); track stat.labelKey) {
|
||||
<article class="account-stat">
|
||||
<div class="account-stat__icon">
|
||||
<mat-icon>{{ stat.icon }}</mat-icon>
|
||||
</div>
|
||||
<div class="account-stat__label">
|
||||
{{ stat.labelKey | translate }}
|
||||
</div>
|
||||
<div
|
||||
class="account-stat__value"
|
||||
[class.account-stat__value--warning]="stat.warning"
|
||||
>
|
||||
{{ stat.value }}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
} @else {
|
||||
@for (item of skeletonStats; track item) {
|
||||
<div class="account-skeleton account-skeleton--stat"></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<mat-dialog-content class="account-dialog__content">
|
||||
@if (loadState() === 'error') {
|
||||
<section class="account-dialog__error-state">
|
||||
<div class="account-dialog__error-icon">
|
||||
<mat-icon>signal_wifi_connected_no_internet_4</mat-icon>
|
||||
</div>
|
||||
<h3>
|
||||
{{ 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' | translate }}
|
||||
</h3>
|
||||
<p>
|
||||
{{
|
||||
'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<button mat-flat-button color="primary" (click)="reload()">
|
||||
{{ 'RETRY' | translate }}
|
||||
</button>
|
||||
</section>
|
||||
} @else if (loadState() === 'loading') {
|
||||
<div class="account-dialog__grid">
|
||||
@for (panel of skeletonPanels; track panel) {
|
||||
<section class="account-panel account-panel--loading">
|
||||
<div
|
||||
class="account-skeleton account-skeleton--heading"
|
||||
></div>
|
||||
<div class="account-skeleton account-skeleton--row"></div>
|
||||
<div class="account-skeleton account-skeleton--row"></div>
|
||||
<div
|
||||
class="account-skeleton account-skeleton--row short"
|
||||
></div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="account-dialog__grid">
|
||||
<section class="account-panel">
|
||||
<div class="account-panel__header">
|
||||
<p class="account-panel__eyebrow">
|
||||
{{ 'STALKER.ACCOUNT_INFO.ACCOUNT' | translate }}
|
||||
</p>
|
||||
<h3>{{ snapshot()?.login || playlistLabel() }}</h3>
|
||||
</div>
|
||||
|
||||
@if (accountDetails().length > 0) {
|
||||
<dl class="account-detail-list">
|
||||
@for (row of accountDetails(); track row.labelKey) {
|
||||
<div class="account-detail-row">
|
||||
<dt>{{ row.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.account-detail-row__value--mono]="
|
||||
row.mono
|
||||
"
|
||||
[class.account-detail-row__value--positive]="
|
||||
row.tone === 'positive'
|
||||
"
|
||||
[class.account-detail-row__value--warning]="
|
||||
row.tone === 'warning'
|
||||
"
|
||||
>
|
||||
@if (translatedRowKeys.has(row.value)) {
|
||||
{{ row.value | translate }}
|
||||
} @else {
|
||||
{{ row.value }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
} @else {
|
||||
<div class="account-dialog__empty-copy">
|
||||
<h4>
|
||||
{{
|
||||
'STALKER.ACCOUNT_INFO.NO_ACCOUNT_DATA_TITLE'
|
||||
| translate
|
||||
}}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'STALKER.ACCOUNT_INFO.NO_ACCOUNT_DATA_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="account-panel">
|
||||
<div class="account-panel__header">
|
||||
<p class="account-panel__eyebrow">
|
||||
{{ 'STALKER.ACCOUNT_INFO.PORTAL_INFO' | translate }}
|
||||
</p>
|
||||
<h3>
|
||||
@if (playlist.isFullStalkerPortal) {
|
||||
{{
|
||||
'STALKER.ACCOUNT_INFO.PORTAL_TYPE_FULL'
|
||||
| translate
|
||||
}}
|
||||
} @else {
|
||||
{{
|
||||
'STALKER.ACCOUNT_INFO.PORTAL_TYPE_LEGACY'
|
||||
| translate
|
||||
}}
|
||||
}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<dl class="account-detail-list">
|
||||
@for (row of portalDetails(); track row.labelKey) {
|
||||
<div class="account-detail-row">
|
||||
<dt>{{ row.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.account-detail-row__value--mono]="
|
||||
row.mono
|
||||
"
|
||||
>
|
||||
{{ row.value }}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="account-dialog__actions">
|
||||
<button mat-stroked-button mat-dialog-close class="account-dialog__close">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
// Mirrors the Xtream account-info dialog's visual language
|
||||
// (libs/portal/xtream/feature/src/lib/account-info/) with the Stalker
|
||||
// provider accent. Keep structural changes in sync between the two.
|
||||
: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-stalker, var(--mat-sys-primary));
|
||||
box-shadow: 0 0 0 4px
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-provider-stalker, 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--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(3, 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);
|
||||
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-stalker, var(--mat-sys-primary)) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--app-provider-stalker, 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;
|
||||
}
|
||||
|
||||
// ─── 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--positive {
|
||||
color: #2db36f;
|
||||
}
|
||||
|
||||
.account-detail-row__value--warning {
|
||||
color: #d18b09;
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
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<StalkerAccountInfoComponent>;
|
||||
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',
|
||||
expireDate: Math.round(Date.now() / 1000) + 30 * 86_400,
|
||||
tariffPlanName: 'Premium',
|
||||
status: 1,
|
||||
};
|
||||
|
||||
const cachedSnapshot = {
|
||||
login: 'cached-user',
|
||||
expireDate: Math.round(Date.now() / 1000) + 3 * 86_400,
|
||||
tariffPlanName: 'Basic',
|
||||
status: 1,
|
||||
};
|
||||
|
||||
async function createComponent(): Promise<void> {
|
||||
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('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('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();
|
||||
});
|
||||
});
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
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 {
|
||||
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<StalkerAccountInfoDialogData>(
|
||||
MAT_DIALOG_DATA
|
||||
);
|
||||
private readonly accountInfoService = inject(StalkerAccountInfoService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly logger = createLogger('StalkerAccountInfo');
|
||||
|
||||
readonly playlist: PlaylistMeta = this.data.playlist;
|
||||
readonly loadState = signal<AccountLoadState>('loading');
|
||||
readonly snapshot = signal<StalkerAccountSnapshot | null>(null);
|
||||
readonly snapshotSource = signal<SnapshotSource | null>(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);
|
||||
});
|
||||
readonly expiresSoon = computed(() => {
|
||||
const daysLeft = this.daysLeft();
|
||||
return daysLeft !== null && daysLeft <= 7;
|
||||
});
|
||||
|
||||
readonly heroStats = computed<AccountStat[]>(() => {
|
||||
const stats: AccountStat[] = [];
|
||||
const daysLeft = this.daysLeft();
|
||||
|
||||
if (daysLeft !== null) {
|
||||
stats.push({
|
||||
icon: 'timer',
|
||||
labelKey:
|
||||
daysLeft < 0
|
||||
? 'STALKER.ACCOUNT_INFO.EXPIRED'
|
||||
: 'STALKER.ACCOUNT_INFO.DAYS_LEFT',
|
||||
value:
|
||||
daysLeft < 0
|
||||
? 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<AccountDetailRow[]>(() => {
|
||||
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<AccountDetailRow[]>(() => [
|
||||
{
|
||||
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<string> = new Set([
|
||||
'STALKER.ACCOUNT_INFO.ACTIVE',
|
||||
'STALKER.ACCOUNT_INFO.INACTIVE',
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
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<void> {
|
||||
if (this.snapshot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const playlist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylist(this.playlist._id)
|
||||
);
|
||||
const cached = playlist?.stalkerAccountInfo;
|
||||
if (cached && Object.values(cached).some((v) => v !== undefined)) {
|
||||
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() : '-';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+6
-2
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+23
-19
@@ -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 {
|
||||
|
||||
+3
@@ -34,6 +34,9 @@
|
||||
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
|
||||
(playlistInfoRequested)="onPlaylistInfoRequested()"
|
||||
(accountInfoRequested)="onAccountInfoRequested()"
|
||||
(accountInfoForPlaylistRequested)="
|
||||
onAccountInfoForPlaylistRequested($event)
|
||||
"
|
||||
(addPlaylistRequested)="onAddPlaylistRequested()"
|
||||
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
|
||||
/>
|
||||
|
||||
+6
@@ -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<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
readonly contextDrawerToggleRequested = output<void>();
|
||||
readonly accountInfoForPlaylistRequested = output<PlaylistMeta>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
+6
-2
@@ -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(),
|
||||
},
|
||||
];
|
||||
|
||||
+55
-11
@@ -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 {
|
||||
|
||||
+96
@@ -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();
|
||||
|
||||
+5
@@ -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();
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
(downloadsRequested)="facade.openDownloadsShortcut()"
|
||||
(playlistInfoRequested)="facade.openPlaylistInfo()"
|
||||
(accountInfoRequested)="facade.openAccountInfo()"
|
||||
(accountInfoForPlaylistRequested)="facade.openAccountInfoFor($event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -208,6 +208,7 @@ class MockWorkspaceShellFacade {
|
||||
runHeaderBulkAction = jest.fn();
|
||||
openPlaylistInfo = jest.fn();
|
||||
openAccountInfo = jest.fn();
|
||||
openAccountInfoFor = jest.fn();
|
||||
closeActiveExternalSession = jest.fn();
|
||||
cancelXtreamImport = jest.fn();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import type { XtreamAccountInfoDialogData } from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
StalkerAccountInfoDialogData,
|
||||
XtreamAccountInfoDialogData,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export type WorkspacePlaylistType =
|
||||
| 'xtream'
|
||||
@@ -11,12 +14,14 @@ export type PlaylistCategory = 'm3u' | 'xtream' | 'stalker';
|
||||
export type M3uSubType = 'url' | 'file' | 'text';
|
||||
|
||||
export type WorkspaceAccountInfoData = XtreamAccountInfoDialogData;
|
||||
export type WorkspaceStalkerAccountInfoData = StalkerAccountInfoDialogData;
|
||||
|
||||
export interface WorkspaceShellActions {
|
||||
openAddPlaylistDialog(type?: WorkspacePlaylistType): void;
|
||||
openGlobalSearch(initialQuery?: string): void;
|
||||
openGlobalRecent(): void;
|
||||
openAccountInfo(data: WorkspaceAccountInfoData): void;
|
||||
openStalkerAccountInfo(data: WorkspaceStalkerAccountInfoData): void;
|
||||
}
|
||||
|
||||
export const WORKSPACE_SHELL_ACTIONS =
|
||||
|
||||
Reference in new issue
Block a user