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:
4grayandClaude Opus 5 committed 2026-08-02 09:16:00 +02:00
1 parent e86e988e72
commit 582785ee8e
52 files changed
+2394 -68

No files matched your search

@@ -34,6 +34,9 @@
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
(playlistInfoRequested)="onPlaylistInfoRequested()"
(accountInfoRequested)="onAccountInfoRequested()"
(accountInfoForPlaylistRequested)="
onAccountInfoForPlaylistRequested($event)
"
(addPlaylistRequested)="onAddPlaylistRequested()"
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
/>
@@ -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();
}
@@ -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(),
},
];
@@ -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 {
@@ -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();
@@ -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();
}