mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -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
+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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user