mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
Merge remote-tracking branch 'origin/master' into agent/download-season-queue
# Conflicts: # apps/electron-backend/src/app/events/database/download-requests.ts # libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts # libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts # libs/ui/components/src/lib/season-container/episode-download.util.ts # libs/ui/components/src/lib/season-container/episode-utils.spec.ts # libs/ui/components/src/lib/season-container/season-container.component.spec.ts # libs/ui/components/src/lib/season-container/season-container.component.ts
This commit is contained in:
commit
31ac306bb0
243 files changed
+12935
-1106
No files matched your search
@@ -109,18 +109,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 599px) {
|
||||
// Matches the phone breakpoint in portal-layout's live-layout mixin, which
|
||||
// this component includes; a narrower one here would leave a band of widths
|
||||
// where only the mixin's generic stacking applied.
|
||||
@media only screen and (max-width: 640px) {
|
||||
:host {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
height: 50vh;
|
||||
// The mixin caps the list's share; here the explicit height is the
|
||||
// whole point of the bottom-drawer layout, so release the cap.
|
||||
max-height: none;
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--mat-sys-outline-variant);
|
||||
border-bottom: none;
|
||||
|
||||
// Sidebar collapse uses width=0 on desktop; on mobile the rail flips
|
||||
// to a bottom drawer so collapse it via height instead.
|
||||
@@ -131,11 +138,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps the mixin's player floor rather than releasing it: `height: 50vh`
|
||||
// is a preference, not a reservation, so on a short landscape phone the
|
||||
// drawer could otherwise squeeze the video away entirely.
|
||||
.content-container {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.sidebar-restore {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ComponentPortal } from '@angular/cdk/portal';
|
||||
import { AsyncPipe, CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
Injector,
|
||||
OnDestroy,
|
||||
@@ -143,6 +144,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
|
||||
})
|
||||
export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly overlay = inject(Overlay);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
@@ -887,6 +889,13 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
if (isTypingInInput(event)) {
|
||||
return;
|
||||
}
|
||||
// Behind the workspace's phone context drawer the route content is
|
||||
// inert; this document-level listener still fires, so it opts out
|
||||
// itself instead of switching channels or toggling the sidebar
|
||||
// behind the modal surface.
|
||||
if (this.hostElement.nativeElement.closest('[inert]')) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.key.toLowerCase() === 'b'
|
||||
|
||||
@@ -60,10 +60,6 @@ function createAbortError(): Error {
|
||||
return error;
|
||||
}
|
||||
|
||||
async function waitForRefreshPreparationPaint(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 160));
|
||||
}
|
||||
|
||||
function createPlaylistMeta(
|
||||
overrides: Partial<PlaylistMeta> = {}
|
||||
): PlaylistMeta {
|
||||
@@ -559,6 +555,13 @@ describe('PlaylistRefreshActionService', () => {
|
||||
hiddenCategories: [];
|
||||
}>();
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
// The service awaits its internal paint delay (rAF + setTimeout)
|
||||
// before it calls deleteXtreamPlaylistContent, so a fixed sleep here
|
||||
// races against real timers under parallel jest load. The mock fires
|
||||
// onEvent synchronously and the service applies it to the signal
|
||||
// synchronously, so resolving this deferred inside the mock is a
|
||||
// deterministic "worker event has been applied" signal.
|
||||
const workerEventDelivered = createDeferred<void>();
|
||||
|
||||
databaseService.deleteXtreamPlaylistContent.mockImplementation(
|
||||
(
|
||||
@@ -576,6 +579,7 @@ describe('PlaylistRefreshActionService', () => {
|
||||
current: 50,
|
||||
total: 100,
|
||||
});
|
||||
workerEventDelivered.resolve();
|
||||
|
||||
return refresh.promise;
|
||||
}
|
||||
@@ -587,7 +591,7 @@ describe('PlaylistRefreshActionService', () => {
|
||||
);
|
||||
|
||||
service.refresh(item);
|
||||
await waitForRefreshPreparationPaint();
|
||||
await workerEventDelivered.promise;
|
||||
|
||||
expect(service.refreshPreparation()).toEqual({
|
||||
playlistId: item._id,
|
||||
|
||||
@@ -202,16 +202,16 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showAddPlaylist() || showAccountInfo()) {
|
||||
@if (showAddPlaylist() || showAccountInfo() || showPlaylistInfo()) {
|
||||
<div class="context-actions-section">
|
||||
@if (showAddPlaylist()) {
|
||||
@if (showPlaylistInfo()) {
|
||||
<button
|
||||
class="context-action-item"
|
||||
(click)="requestAddPlaylist()"
|
||||
(click)="requestPlaylistInfo()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
|
||||
'WORKSPACE.SHELL.PLAYLIST_INFO' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
@@ -226,6 +226,17 @@
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
@if (showAddPlaylist()) {
|
||||
<button
|
||||
class="context-action-item"
|
||||
(click)="requestAddPlaylist()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-menu>
|
||||
@@ -236,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>
|
||||
|
||||
@@ -90,6 +90,25 @@
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
// A wrapping subtitle pushes the trigger past the 56px header row.
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// On a phone the header row has no width to spare, and the shell allows this
|
||||
// trigger to shrink to 88px there. The decorative type icon goes so that the
|
||||
// trigger's fixed chrome (refresh, chevron, gaps, padding) actually fits that
|
||||
// floor with the name ellipsizing; the subtitle goes because the name is the
|
||||
// only part worth the room.
|
||||
@media (max-width: 640px) {
|
||||
.trigger-icon-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playlist-info .channels-count {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,54 @@ describe('PlaylistSwitcherComponent', () => {
|
||||
expect(accountInfoSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders playlist info, account info, and add playlist context actions in the menu', async () => {
|
||||
await createComponent();
|
||||
fixture.componentRef.setInput('showPlaylistInfo', true);
|
||||
fixture.componentRef.setInput('showAccountInfo', true);
|
||||
fixture.componentRef.setInput('showAddPlaylist', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const playlistInfoSpy = jest.fn();
|
||||
component.playlistInfoRequested.subscribe(playlistInfoSpy);
|
||||
|
||||
component.menuTrigger().openMenu();
|
||||
fixture.detectChanges();
|
||||
|
||||
const actionButtons = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'.context-actions-section .context-action-item'
|
||||
)
|
||||
);
|
||||
const labels = actionButtons.map((button) =>
|
||||
button.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(labels).toEqual([
|
||||
expect.stringContaining('PLAYLIST_INFO'),
|
||||
expect.stringContaining('ACCOUNT_INFO'),
|
||||
expect.stringContaining('ADD_PLAYLIST'),
|
||||
]);
|
||||
|
||||
actionButtons[0].click();
|
||||
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();
|
||||
|
||||
+7
@@ -234,6 +234,13 @@ mat-paginator {
|
||||
|
||||
mat-paginator {
|
||||
justify-self: start;
|
||||
|
||||
// The page-size select wraps onto a line of its own here, costing a
|
||||
// row of an already short screen for a control nobody reaches for
|
||||
// while browsing on a phone. The range and the arrows stay.
|
||||
::ng-deep .mat-mdc-paginator-page-size {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ describe('StreamResolverService', () => {
|
||||
let xtreamApi: { getShortEpg: jest.Mock };
|
||||
let xtreamUrl: { constructLiveUrl: jest.Mock };
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
let stalkerSession: { makeAuthenticatedRequest: jest.Mock };
|
||||
let stalkerSession: {
|
||||
getCachedToken: jest.Mock;
|
||||
makeAuthenticatedRequest: jest.Mock;
|
||||
};
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -40,6 +43,7 @@ describe('StreamResolverService', () => {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
stalkerSession = {
|
||||
getCachedToken: jest.fn(() => null),
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
};
|
||||
epgBridge = {
|
||||
@@ -670,6 +674,10 @@ describe('StreamResolverService', () => {
|
||||
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
|
||||
// A direct radio URL on a host foreign to the portal gets the
|
||||
// credential-free KSPlayer direct-stream profile — the same rule the
|
||||
// Stalker live layout applies (the previous playlist-field passthrough
|
||||
// predated the shared profile classifier).
|
||||
expect(detail).toEqual(
|
||||
expect.objectContaining({
|
||||
epgMode: 'portal',
|
||||
@@ -680,20 +688,152 @@ describe('StreamResolverService', () => {
|
||||
radio: 'true',
|
||||
url: 'https://media.example.com/direct-radio.mp3',
|
||||
http: expect.objectContaining({
|
||||
referrer: 'https://ref.example.com',
|
||||
'user-agent': 'IPTVnator',
|
||||
origin: 'https://origin.example.com',
|
||||
'user-agent': 'KSPlayer',
|
||||
}),
|
||||
}),
|
||||
playback: expect.objectContaining({
|
||||
streamUrl: 'https://media.example.com/direct-radio.mp3',
|
||||
title: 'Direct Radio',
|
||||
thumbnail: 'direct-radio.png',
|
||||
userAgent: 'IPTVnator',
|
||||
referer: 'https://ref.example.com',
|
||||
origin: 'https://origin.example.com',
|
||||
userAgent: 'KSPlayer',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(detail.playback.headers?.['Cookie']).toBeUndefined();
|
||||
expect(detail.playback.headers?.['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves relative Stalker create_link responses against the portal base', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
// Portals frequently answer create_link with a solution-prefixed
|
||||
// relative path; the shared normalizer must resolve it instead of
|
||||
// handing the bare path to the player (the old weak normalizer did).
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg /media/file_123.mpg' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::88',
|
||||
name: 'Relative Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '88',
|
||||
stalkerCmd: 'ffrt3 http://stalker.example.com/media/999.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe(
|
||||
'https://stalker.example.com/stalker_portal/media/file_123.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches the portal header set to Stalker collection playback on the portal host', async () => {
|
||||
// The collection routes must carry the same credentials as the live
|
||||
// layout — an auth-gated stream opened from Favorites/Recent 403s
|
||||
// without the mac cookie and Bearer token (Codex round-3 finding).
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: true,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue('TOKEN77');
|
||||
stalkerSession.makeAuthenticatedRequest.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg https://stalker.example.com:8080/live/88.ts' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::88',
|
||||
name: 'Gated Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '88',
|
||||
stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.getCachedToken).toHaveBeenCalledWith('stalker-1');
|
||||
expect(playback.headers?.['Cookie']).toContain(
|
||||
'mac=00:11:22:33:44:55'
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN77');
|
||||
expect(playback.userAgent).toBe(playback.headers?.['User-Agent']);
|
||||
expect(playback.referer).toBe('https://stalker.example.com');
|
||||
expect(playback.origin).toBe('https://stalker.example.com');
|
||||
});
|
||||
|
||||
it('keeps Stalker collection playback from a foreign CDN credential-free', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
stalkerSession.getCachedToken.mockReturnValue('TOKEN77');
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg http://cdn.other.example/live/88.ts' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::88',
|
||||
name: 'Direct Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '88',
|
||||
stalkerCmd: 'ffrt3 http://stalker.example.com/media/88.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.headers?.['Cookie']).toBeUndefined();
|
||||
expect(playback.headers?.['Authorization']).toBeUndefined();
|
||||
expect(playback.headers?.['User-Agent']).toBe('KSPlayer');
|
||||
expect(playback.referer).toBeUndefined();
|
||||
expect(playback.origin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('appends query-only Stalker create_link responses to the original cmd URL', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: '?token=xyz' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::89',
|
||||
name: 'Token Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '89',
|
||||
stalkerCmd: 'auto http://cdn.example.com/live/89.m3u8',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example.com/live/89.m3u8?token=xyz'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,14 @@ import {
|
||||
XtreamApiService,
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
buildStalkerExternalPlaybackHeaders,
|
||||
getStalkerPortalOrigin,
|
||||
isCrossOriginStalkerStream,
|
||||
normalizeStalkerPlaybackCommand,
|
||||
resolveStalkerPlaybackUrl,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
@@ -323,16 +330,15 @@ export class StreamResolverService {
|
||||
const portalUrl =
|
||||
item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
|
||||
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
|
||||
const normalizedCmd = this.normalizeStalkerCmd(item.stalkerCmd ?? '');
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(
|
||||
item.stalkerCmd ?? ''
|
||||
);
|
||||
if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) {
|
||||
return {
|
||||
return this.buildStalkerPlayback(item, playlist, {
|
||||
macAddress,
|
||||
portalUrl,
|
||||
streamUrl: normalizedCmd,
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
userAgent: playlist?.userAgent,
|
||||
referer: playlist?.referrer,
|
||||
origin: playlist?.origin,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = item.radio === 'true' ? 'radio' : 'itv';
|
||||
@@ -361,11 +367,71 @@ export class StreamResolverService {
|
||||
|
||||
const rawCmd = response?.js?.cmd ?? '';
|
||||
|
||||
return this.buildStalkerPlayback(item, playlist, {
|
||||
macAddress,
|
||||
portalUrl,
|
||||
// Shared normalizer from the Stalker store: strips the solution
|
||||
// prefix and resolves relative `/media/...` or `?...` responses
|
||||
// against the portal base instead of returning them verbatim.
|
||||
streamUrl: resolveStalkerPlaybackUrl(
|
||||
portalUrl,
|
||||
item.stalkerCmd ?? '',
|
||||
rawCmd
|
||||
),
|
||||
isLive: item.radio === 'true' ? undefined : true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The collection routes must hand players the SAME portal header set the
|
||||
* Stalker live layout builds — an auth-gated stream opened from Favorites
|
||||
* or Recently Viewed 403s without the mac cookie/Bearer token exactly
|
||||
* like one opened from the portal itself (the header owner then scopes
|
||||
* them to the stream origin; foreign hosts get the credential-free
|
||||
* profile from the shared classifier).
|
||||
*/
|
||||
private buildStalkerPlayback(
|
||||
item: UnifiedCollectionItem,
|
||||
playlist: Playlist | undefined,
|
||||
resolved: {
|
||||
macAddress: string;
|
||||
portalUrl: string;
|
||||
streamUrl: string;
|
||||
isLive?: boolean;
|
||||
}
|
||||
): ResolvedPortalPlayback {
|
||||
// The item may carry portal/mac overrides for playlists that no
|
||||
// longer exist; the builder only reads header-relevant fields.
|
||||
const headerPlaylist = {
|
||||
...(playlist ?? {}),
|
||||
macAddress: resolved.macAddress,
|
||||
portalUrl: resolved.portalUrl,
|
||||
} as Playlist;
|
||||
const token = this.stalkerSession.getCachedToken(item.playlistId);
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
headerPlaylist,
|
||||
token,
|
||||
resolved.streamUrl
|
||||
);
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
headerPlaylist,
|
||||
resolved.streamUrl
|
||||
);
|
||||
const portalOrigin = getStalkerPortalOrigin(headerPlaylist);
|
||||
|
||||
return {
|
||||
streamUrl: this.normalizeStalkerCmd(rawCmd),
|
||||
streamUrl: resolved.streamUrl,
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
isLive: item.radio === 'true' ? undefined : true,
|
||||
isLive: resolved.isLive,
|
||||
headers,
|
||||
userAgent: headers['User-Agent'] || playlist?.userAgent,
|
||||
referer: crossOriginStream
|
||||
? undefined
|
||||
: playlist?.referrer || portalOrigin,
|
||||
origin: crossOriginStream
|
||||
? undefined
|
||||
: playlist?.origin || portalOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1083,26 +1149,6 @@ export class StreamResolverService {
|
||||
}));
|
||||
}
|
||||
|
||||
private normalizeStalkerCmd(value: string): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const splitAt = trimmed.indexOf(' ');
|
||||
if (splitAt > 0) {
|
||||
const candidate = trimmed.slice(splitAt + 1).trim();
|
||||
if (
|
||||
candidate.startsWith('http://') ||
|
||||
candidate.startsWith('https://')
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private isHttpUrl(value: string): boolean {
|
||||
return value.startsWith('http://') || value.startsWith('https://');
|
||||
}
|
||||
|
||||
@@ -169,3 +169,23 @@
|
||||
@include grid.content-grid;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
// The header packs the title, the type filters and the scope toggle into one
|
||||
// row. On a phone that row is wider than the screen, and the scope checkbox
|
||||
// was the part pushed off the right edge, so the row becomes a column.
|
||||
@media (max-width: 640px) {
|
||||
.header .header-top {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header .header-left {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header .header-right {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
+34
@@ -743,6 +743,15 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
});
|
||||
|
||||
it('renders inline audio for Stalker radio items and skips external playback', async () => {
|
||||
// Radio renders the dedicated audio player, never the shared web
|
||||
// player wrapper — so this test also pins that the tab itself
|
||||
// configures the scoped header override (portal cookie/token for
|
||||
// auth-gated streams) before the audio element gets the URL, and
|
||||
// clears it again on close.
|
||||
const setUserAgent = jest.fn().mockResolvedValue(true);
|
||||
(
|
||||
window.electron as unknown as Record<string, unknown>
|
||||
)['setUserAgent'] = setUserAgent;
|
||||
const item = {
|
||||
...buildLiveItem('stalker'),
|
||||
name: 'Jazz Radio',
|
||||
@@ -754,6 +763,12 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
streamUrl: 'https://example.com/jazz.mp3',
|
||||
title: 'Jazz Radio',
|
||||
thumbnail: 'jazz.png',
|
||||
headers: {
|
||||
'User-Agent': 'MAG250',
|
||||
Referer: 'http://portal.example',
|
||||
Cookie: 'mac=00:1A:79:00:00:01',
|
||||
Authorization: 'Bearer TOKEN99',
|
||||
},
|
||||
},
|
||||
channel: {
|
||||
id: '40001',
|
||||
@@ -802,6 +817,25 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
expect(audioPlayer.url()).toBe('https://example.com/jazz.mp3');
|
||||
expect(audioPlayer.icon()).toBe('jazz.png');
|
||||
expect(audioPlayer.channelName()).toBe('Jazz Radio');
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
'https://example.com/jazz.mp3',
|
||||
{
|
||||
authorization: 'Bearer TOKEN99',
|
||||
cookie: 'mac=00:1A:79:00:00:01',
|
||||
}
|
||||
);
|
||||
|
||||
component.onClose();
|
||||
|
||||
// Closing the radio player must drop the portal credentials.
|
||||
expect(setUserAgent).toHaveBeenLastCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
'https://example.com/jazz.mp3'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders shared EPG view for Xtream items and records recent history', async () => {
|
||||
|
||||
+36
-1
@@ -55,6 +55,7 @@ import { GlobalFavoritesListComponent } from '../global-favorites-list/global-fa
|
||||
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ElectronStreamHeadersService,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
@@ -103,9 +104,12 @@ export class UnifiedLiveTabComponent {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly streamHeaders = inject(ElectronStreamHeadersService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
/** Stream URL of the radio playback whose header override this tab configured. */
|
||||
private radioHeaderScopeUrl: string | null = null;
|
||||
|
||||
readonly player = this.settingsStore.player;
|
||||
readonly supportsEpg = this.runtime.supportsEpg;
|
||||
@@ -338,7 +342,13 @@ export class UnifiedLiveTabComponent {
|
||||
() => this.progressTick.update((tick) => tick + 1),
|
||||
30_000
|
||||
);
|
||||
this.destroyRef.onDestroy(() => clearInterval(tickInterval));
|
||||
this.destroyRef.onDestroy(() => {
|
||||
clearInterval(tickInterval);
|
||||
// Invalidate a playback continuation still awaiting its header
|
||||
// IPC and drop any radio credentials owned by this tab.
|
||||
this.selectionRequestId += 1;
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
});
|
||||
}
|
||||
|
||||
async onChannelSelected(channel: UnifiedFavoriteChannel): Promise<void> {
|
||||
@@ -539,6 +549,10 @@ export class UnifiedLiveTabComponent {
|
||||
this.activeUid.set(null);
|
||||
this.activeItem.set(null);
|
||||
this.activeTimeshift.set(null);
|
||||
// Radio credentials must not outlive the closed player; the service
|
||||
// no-ops when a newer playback already owns the override slot.
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
this.radioHeaderScopeUrl = null;
|
||||
}
|
||||
|
||||
onEpgMappingChanged(): void {
|
||||
@@ -577,6 +591,12 @@ export class UnifiedLiveTabComponent {
|
||||
this.activeDetail.set(null);
|
||||
this.activeTimeshift.set(null);
|
||||
this.isSelecting.set(true);
|
||||
// A previously owned radio override must not survive into a
|
||||
// selection that never mounts a player surface of its own — external
|
||||
// video playback and failed resolutions would otherwise keep the old
|
||||
// radio credentials installed for that origin.
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
this.radioHeaderScopeUrl = null;
|
||||
|
||||
try {
|
||||
const detail =
|
||||
@@ -587,6 +607,21 @@ export class UnifiedLiveTabComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.radio === 'true') {
|
||||
// Radio renders the dedicated audio player, never
|
||||
// WebPlayerViewComponent, so the scoped Electron header
|
||||
// override (portal cookie/token for auth-gated streams) is
|
||||
// configured here BEFORE the audio element gets the URL.
|
||||
// Ownership is claimed synchronously so a close/destroy
|
||||
// during the pending IPC can still clear the credentials.
|
||||
const headerSync = this.streamHeaders.apply(detail.playback);
|
||||
this.radioHeaderScopeUrl = detail.playback.streamUrl;
|
||||
const stillCurrent = headerSync ? await headerSync : true;
|
||||
if (!stillCurrent || requestId !== this.selectionRequestId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.activeDetail.set(detail);
|
||||
|
||||
if (this.supportsEpg && detail.epgMode === 'm3u') {
|
||||
|
||||
@@ -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,317 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { PlaylistMeta, STALKER_REQUEST } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
normalizeStoredStalkerAccountInfo,
|
||||
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: {
|
||||
refreshAccountProfile: 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 = {
|
||||
refreshAccountProfile: 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.refreshAccountProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-authenticates full portals and maps the profile account block', async () => {
|
||||
stalkerSession.refreshAccountProfile.mockResolvedValue({
|
||||
login: 'user-77',
|
||||
expire_date: 1_790_000_000,
|
||||
tariff_plan_name: 'Premium',
|
||||
status: '1',
|
||||
});
|
||||
|
||||
const snapshot = await service.fetchAccountInfo(fullPortalPlaylist);
|
||||
|
||||
// Routed through the session service so the handshake serializes
|
||||
// with ensureToken() and the new token replaces the cached one.
|
||||
expect(stalkerSession.refreshAccountProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: fullPortalPlaylist._id })
|
||||
);
|
||||
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.refreshAccountProfile.mockResolvedValue(undefined);
|
||||
|
||||
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: expect.objectContaining({
|
||||
type: 'account_info',
|
||||
action: 'get_main_info',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(snapshot).toEqual({
|
||||
login: 'Fallback Name',
|
||||
expireDate: Math.round(new Date(2026, 9, 1).getTime() / 1000),
|
||||
tariffPlanName: 'Basic 30',
|
||||
status: 1,
|
||||
mac: '00:1A:79:00:00:01',
|
||||
phone: '10042',
|
||||
});
|
||||
});
|
||||
|
||||
it('unwraps the nested js.account_info envelope used by Ministra-style portals', async () => {
|
||||
// Same envelope fetchStalkerExpireDate() consumes in
|
||||
// stalker-player-request.utils — nested facts must win over
|
||||
// flat aliases.
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: {
|
||||
mac: '00:1A:79:00:00:01',
|
||||
tariff_plan: 'Flat Alias Plan',
|
||||
account_info: {
|
||||
login: 'nested-user',
|
||||
expire_date: 1_790_000_000,
|
||||
tariff_plan_name: 'Nested Premium',
|
||||
status: '1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await service.fetchAccountInfo(portalPlaylist);
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
login: 'nested-user',
|
||||
expireDate: 1_790_000_000,
|
||||
tariffPlanName: 'Nested Premium',
|
||||
status: 1,
|
||||
mac: '00:1A:79:00:00:01',
|
||||
phone: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the JsHttpRequest parameter legacy panels expect', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({ js: {} });
|
||||
|
||||
await service.fetchAccountInfo(portalPlaylist);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ JsHttpRequest: '1-xml' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a stalker_portal URL as a full portal when the flag is absent', async () => {
|
||||
// Restored older backups persist `undefined` for the flag after the
|
||||
// one-shot metadata migration already ran.
|
||||
stalkerSession.refreshAccountProfile.mockResolvedValue({
|
||||
login: 'restored-user',
|
||||
});
|
||||
|
||||
const snapshot = await service.fetchAccountInfo({
|
||||
...portalPlaylist,
|
||||
portalUrl: 'http://portal.example/stalker_portal/c/',
|
||||
isFullStalkerPortal: undefined,
|
||||
} as PlaylistMeta);
|
||||
|
||||
expect(stalkerSession.refreshAccountProfile).toHaveBeenCalled();
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
expect(snapshot?.login).toBe('restored-user');
|
||||
});
|
||||
|
||||
it('keeps portal.php panels on the legacy path when the flag is absent', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({ js: { login: 'legacy' } });
|
||||
|
||||
await service.fetchAccountInfo({
|
||||
...portalPlaylist,
|
||||
isFullStalkerPortal: undefined,
|
||||
} as PlaylistMeta);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalled();
|
||||
expect(stalkerSession.refreshAccountProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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('normalizeStoredStalkerAccountInfo', () => {
|
||||
it('parses stringly-typed persisted values from the import path', () => {
|
||||
expect(
|
||||
normalizeStoredStalkerAccountInfo({
|
||||
login: 'user-1',
|
||||
expireDate: '2026-10-01' as unknown as number,
|
||||
tariffPlanName: 'Premium',
|
||||
status: '1' as unknown as number,
|
||||
})
|
||||
).toEqual({
|
||||
login: 'user-1',
|
||||
expireDate: Math.round(new Date(2026, 9, 1).getTime() / 1000),
|
||||
tariffPlanName: 'Premium',
|
||||
status: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through well-formed numeric snapshots unchanged', () => {
|
||||
expect(
|
||||
normalizeStoredStalkerAccountInfo({
|
||||
login: 'user-1',
|
||||
expireDate: 1_790_000_000,
|
||||
tariffPlanName: 'Premium',
|
||||
status: 1,
|
||||
})
|
||||
).toEqual({
|
||||
login: 'user-1',
|
||||
expireDate: 1_790_000_000,
|
||||
tariffPlanName: 'Premium',
|
||||
status: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for missing or fact-free snapshots', () => {
|
||||
expect(normalizeStoredStalkerAccountInfo(undefined)).toBeNull();
|
||||
expect(normalizeStoredStalkerAccountInfo(null)).toBeNull();
|
||||
expect(normalizeStoredStalkerAccountInfo({})).toBeNull();
|
||||
expect(
|
||||
normalizeStoredStalkerAccountInfo({
|
||||
expireDate: '0000-00-00' as unknown as number,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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(new Date(2026, 9, 1).getTime() / 1000)
|
||||
);
|
||||
});
|
||||
|
||||
it('reads a bare date as a local calendar day, not UTC midnight', () => {
|
||||
// Date.parse('2026-10-01') is UTC midnight, which renders as
|
||||
// September 30 anywhere west of UTC.
|
||||
const parsed = parseStalkerDate('2026-10-01') as number;
|
||||
const rendered = new Date(parsed * 1000);
|
||||
|
||||
expect(rendered.getFullYear()).toBe(2026);
|
||||
expect(rendered.getMonth()).toBe(9);
|
||||
expect(rendered.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps timestamps that carry a time or offset on standard parsing', () => {
|
||||
expect(parseStalkerDate('2026-10-01T12:00:00Z')).toBe(
|
||||
Math.round(Date.parse('2026-10-01T12:00:00Z') / 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();
|
||||
});
|
||||
|
||||
it('rejects out-of-range calendar components instead of normalizing them', () => {
|
||||
// new Date(2026, -1, 0) silently becomes Nov 30, 2025 — a
|
||||
// placeholder must not fabricate an expiry.
|
||||
expect(parseStalkerDate('2026-00-00')).toBeUndefined();
|
||||
expect(parseStalkerDate('2026-02-30')).toBeUndefined();
|
||||
expect(parseStalkerDate('2026-13-01')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects negative unlimited-expiry sentinels instead of parsing them as dates', () => {
|
||||
// V8 reads Date.parse('-1') as January 1, 2001 — an unlimited
|
||||
// account must not render as expired.
|
||||
expect(parseStalkerDate('-1')).toBeUndefined();
|
||||
expect(parseStalkerDate(-1)).toBeUndefined();
|
||||
expect(parseStalkerDate('0')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
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 StalkerMainInfoFields {
|
||||
mac?: string;
|
||||
phone?: string;
|
||||
login?: string;
|
||||
fname?: string;
|
||||
status?: number | string;
|
||||
tariff_plan?: string;
|
||||
tariff_plan_name?: string;
|
||||
end_date?: number | string;
|
||||
expire_date?: number | string;
|
||||
expire_billing_date?: number | string;
|
||||
}
|
||||
|
||||
interface StalkerMainInfoResponse {
|
||||
js?:
|
||||
| (StalkerMainInfoFields & {
|
||||
/**
|
||||
* Ministra-style portals nest the account block —
|
||||
* `fetchStalkerExpireDate()` in stalker-player-request.utils
|
||||
* reads this exact envelope. Other panels answer flat.
|
||||
*/
|
||||
account_info?: StalkerMainInfoFields | null;
|
||||
})
|
||||
| null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (isFullStalkerPortalPlaylist(playlist)) {
|
||||
return this.fetchViaProfile(playlist);
|
||||
}
|
||||
|
||||
return this.fetchViaMainInfo(playlist);
|
||||
}
|
||||
|
||||
private async fetchViaProfile(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<StalkerAccountSnapshot | null> {
|
||||
// Goes through the session service rather than calling
|
||||
// authenticate() directly: it serializes with any in-flight
|
||||
// ensureToken() and republishes the resulting token, so the
|
||||
// dialog cannot strand an active portal session on a token its
|
||||
// own handshake invalidated.
|
||||
const accountInfo = await this.stalkerSession.refreshAccountProfile(
|
||||
toStalkerSessionPlaylist(playlist)
|
||||
);
|
||||
|
||||
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',
|
||||
JsHttpRequest: '1-xml',
|
||||
}
|
||||
);
|
||||
|
||||
const flat = response?.js;
|
||||
if (!flat || typeof flat !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Nested envelope wins field-by-field; flat top-level values remain
|
||||
// as aliases for panels that answer without the account_info block.
|
||||
const nested =
|
||||
flat.account_info && typeof flat.account_info === 'object'
|
||||
? flat.account_info
|
||||
: {};
|
||||
const info: StalkerMainInfoFields = { ...flat, ...nested };
|
||||
|
||||
return normalizeSnapshot({
|
||||
login: info.login || info.fname || undefined,
|
||||
expireDate:
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a playlist should use the full `/stalker_portal/` flow.
|
||||
*
|
||||
* The persisted flag is authoritative when present, but a playlist
|
||||
* restored from an older backup can carry `undefined` after the one-shot
|
||||
* metadata migration has already run — fall back to the same URL rule
|
||||
* that migration uses (`withExplicitLegacyStalkerPortalFlag` in
|
||||
* PlaylistsService) rather than mislabelling it as a legacy panel.
|
||||
*/
|
||||
export function isFullStalkerPortalPlaylist(playlist: PlaylistMeta): boolean {
|
||||
if (playlist.isFullStalkerPortal !== undefined) {
|
||||
return Boolean(playlist.isFullStalkerPortal);
|
||||
}
|
||||
|
||||
const portalUrl = playlist.portalUrl ?? playlist.url ?? '';
|
||||
return (
|
||||
portalUrl.includes('/stalker_portal') ||
|
||||
portalUrl.includes('/server/load.php')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSnapshot(
|
||||
snapshot: StalkerAccountSnapshot
|
||||
): StalkerAccountSnapshot | null {
|
||||
const hasAnyFact = Object.values(snapshot).some(
|
||||
(value) => value !== undefined
|
||||
);
|
||||
return hasAnyFact ? snapshot : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the `Playlist.stalkerAccountInfo` snapshot persisted at import
|
||||
* time. The import path stores the portal's `expire_date`/`status` verbatim,
|
||||
* so despite the declared number types the runtime values can be date
|
||||
* strings or millisecond timestamps — run them through the same parsers the
|
||||
* fresh path uses before the dialog renders them.
|
||||
*/
|
||||
export function normalizeStoredStalkerAccountInfo(
|
||||
cached:
|
||||
| {
|
||||
login?: string;
|
||||
expireDate?: number | string;
|
||||
tariffPlanName?: string;
|
||||
status?: number | string;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
): StalkerAccountSnapshot | null {
|
||||
if (!cached || typeof cached !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeSnapshot({
|
||||
login: cached.login || undefined,
|
||||
expireDate: parseStalkerDate(cached.expireDate),
|
||||
tariffPlanName: cached.tariffPlanName || undefined,
|
||||
status: parseStalkerNumber(cached.status),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// Signed numerics included: portals encode "unlimited" as "-1"/"0",
|
||||
// and Date.parse('-1') would otherwise read as January 1, 2001.
|
||||
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 text = String(value).trim();
|
||||
|
||||
// A bare `YYYY-MM-DD` is a calendar date, but Date.parse reads it as
|
||||
// UTC midnight — rendered locally that shows the previous day west of
|
||||
// UTC and moves the days-left boundary. Build it in local time instead.
|
||||
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
||||
if (dateOnly) {
|
||||
const year = Number(dateOnly[1]);
|
||||
const month = Number(dateOnly[2]);
|
||||
const day = Number(dateOnly[3]);
|
||||
const local = new Date(year, month - 1, day);
|
||||
// Round-trip check: the multi-argument constructor normalizes
|
||||
// out-of-range components ('2026-00-00' → Nov 30, 2025), which
|
||||
// would fabricate an expiry from a placeholder.
|
||||
if (
|
||||
local.getFullYear() !== year ||
|
||||
local.getMonth() !== month - 1 ||
|
||||
local.getDate() !== day
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = local.getTime();
|
||||
return Number.isFinite(timestamp) && timestamp > 0
|
||||
? Math.round(timestamp / 1000)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(text);
|
||||
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,5 +1,10 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { buildStalkerExternalPlaybackHeaders } from './stalker-live-playback.utils';
|
||||
import {
|
||||
STALKER_MAG_USER_AGENT,
|
||||
STALKER_STREAM_USER_AGENT,
|
||||
buildStalkerExternalPlaybackHeaders,
|
||||
isCrossOriginStalkerStream,
|
||||
} from './stalker-live-playback.utils';
|
||||
import { STALKER_SERIAL_NUMBER } from './stalker-session.service';
|
||||
|
||||
function createPlaylist(
|
||||
@@ -63,4 +68,118 @@ describe('buildStalkerExternalPlaybackHeaders', () => {
|
||||
const cfduid = headers['Cookie']?.match(/__cfduid=([^;]+)/)?.[1];
|
||||
expect(cfduid).toHaveLength(32);
|
||||
});
|
||||
|
||||
it('sends the MAG User-Agent alongside X-User-Agent for portal-owned streams', () => {
|
||||
// The API request path always sent both; the playback header set
|
||||
// previously carried only X-User-Agent (audit finding 5).
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist(),
|
||||
'TOKEN',
|
||||
'http://portal.test/live/ch1.ts'
|
||||
);
|
||||
|
||||
expect(headers['User-Agent']).toBe(STALKER_MAG_USER_AGENT);
|
||||
expect(headers['X-User-Agent']).toBe(STALKER_MAG_USER_AGENT);
|
||||
expect(headers['Cookie']).toContain('mac=00:1A:79:AA:BB:CC');
|
||||
expect(headers['Authorization']).toBe('Bearer TOKEN');
|
||||
});
|
||||
|
||||
it('lets a playlist-level custom User-Agent take precedence over the MAG UA', () => {
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist({ userAgent: 'CustomAgent/9.9' }),
|
||||
'TOKEN',
|
||||
'http://portal.test/live/ch1.ts'
|
||||
);
|
||||
|
||||
expect(headers['User-Agent']).toBe('CustomAgent/9.9');
|
||||
expect(headers['X-User-Agent']).toBe(STALKER_MAG_USER_AGENT);
|
||||
});
|
||||
|
||||
it('keeps portal credentials for a same-host stream on another port', () => {
|
||||
// Panels routinely serve streams from :8080 next to the portal on
|
||||
// :80, and exactly those streams are gated on the portal cookie
|
||||
// (#1158 class) — a strict origin comparison used to push them onto
|
||||
// the credential-free KSPlayer profile.
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist(),
|
||||
'TOKEN',
|
||||
'http://portal.test:8080/live/ch1.ts'
|
||||
);
|
||||
|
||||
expect(headers['Cookie']).toContain('mac=00:1A:79:AA:BB:CC');
|
||||
expect(headers['Authorization']).toBe('Bearer TOKEN');
|
||||
expect(headers['User-Agent']).toBe(STALKER_MAG_USER_AGENT);
|
||||
});
|
||||
|
||||
it('uses the credential-free direct profile for foreign hosts', () => {
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist(),
|
||||
'TOKEN',
|
||||
'http://cdn.other.test/live/ch1.ts'
|
||||
);
|
||||
|
||||
expect(headers['User-Agent']).toBe(STALKER_STREAM_USER_AGENT);
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the credential-free profile on an https→http downgrade', () => {
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
createPlaylist({
|
||||
portalUrl: 'https://portal.test/stalker_portal/c/index.html',
|
||||
}),
|
||||
'TOKEN',
|
||||
'http://portal.test/live/ch1.ts'
|
||||
);
|
||||
|
||||
expect(headers['User-Agent']).toBe(STALKER_STREAM_USER_AGENT);
|
||||
expect(headers['Cookie']).toBeUndefined();
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCrossOriginStalkerStream', () => {
|
||||
it('treats same-host port and scheme-upgrade changes as portal-owned', () => {
|
||||
const playlist = createPlaylist();
|
||||
|
||||
expect(
|
||||
isCrossOriginStalkerStream(
|
||||
playlist,
|
||||
'http://portal.test:8080/live/ch1.ts'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossOriginStalkerStream(
|
||||
playlist,
|
||||
'https://portal.test/live/ch1.ts'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('treats foreign hosts and TLS downgrades as cross-origin', () => {
|
||||
expect(
|
||||
isCrossOriginStalkerStream(
|
||||
createPlaylist(),
|
||||
'http://cdn.other.test/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossOriginStalkerStream(
|
||||
createPlaylist({
|
||||
portalUrl:
|
||||
'https://portal.test/stalker_portal/c/index.html',
|
||||
}),
|
||||
'http://portal.test/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the playlist or stream URL is missing', () => {
|
||||
expect(isCrossOriginStalkerStream(undefined, 'http://x.test/1')).toBe(
|
||||
false
|
||||
);
|
||||
expect(isCrossOriginStalkerStream(createPlaylist(), undefined)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
STALKER_MAG_USER_AGENT,
|
||||
isStalkerStreamCredentialSafe,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildStalkerSerialCfduid,
|
||||
normalizeStalkerSerialNumber,
|
||||
} from './stalker-identity.utils';
|
||||
|
||||
export const STALKER_MAG_USER_AGENT =
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
|
||||
export { STALKER_MAG_USER_AGENT };
|
||||
export const STALKER_STREAM_USER_AGENT = 'KSPlayer';
|
||||
|
||||
export function getStalkerPortalOrigin(
|
||||
@@ -23,20 +26,24 @@ export function getStalkerPortalOrigin(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the stream must be treated as foreign to the portal — a
|
||||
* different HOST or an https→http downgrade — and therefore must not carry
|
||||
* the portal's credentials. A same-host stream on another port or an
|
||||
* upgraded scheme stays portal-owned: IPTV panels routinely serve streams
|
||||
* from `:8080` next to the portal on `:80`, and those are exactly the
|
||||
* streams gated on the portal's mac cookie/token (#1158 class; curl
|
||||
* semantics, matching the validated redirect layer).
|
||||
*/
|
||||
export function isCrossOriginStalkerStream(
|
||||
playlist: PlaylistMeta | undefined | null,
|
||||
streamUrl?: string
|
||||
): boolean {
|
||||
const portalOrigin = getStalkerPortalOrigin(playlist);
|
||||
if (!portalOrigin || !streamUrl) {
|
||||
if (!playlist?.portalUrl || !streamUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(streamUrl).origin !== portalOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return !isStalkerStreamCredentialSafe(playlist.portalUrl, streamUrl);
|
||||
}
|
||||
|
||||
export function buildStalkerExternalPlaybackHeaders(
|
||||
@@ -48,6 +55,10 @@ export function buildStalkerExternalPlaybackHeaders(
|
||||
return {};
|
||||
}
|
||||
|
||||
// Foreign-host (or TLS-downgraded) streams get the credential-free
|
||||
// KSPlayer direct-stream profile: their access token travels in the URL
|
||||
// that create_link minted, and the portal's mac cookie/Bearer token must
|
||||
// never reach a third-party host.
|
||||
if (isCrossOriginStalkerStream(playlist, streamUrl)) {
|
||||
return {
|
||||
'User-Agent': STALKER_STREAM_USER_AGENT,
|
||||
@@ -71,8 +82,14 @@ export function buildStalkerExternalPlaybackHeaders(
|
||||
cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`);
|
||||
}
|
||||
|
||||
// Both the real User-Agent and Stalker's X-User-Agent, matching what the
|
||||
// API request path sends — a portal that filters streams on the MAG UA
|
||||
// never saw it here before (audit finding: same-origin set only
|
||||
// X-User-Agent). A playlist-level custom UA keeps precedence.
|
||||
const magUserAgent = playlist.userAgent?.trim() || STALKER_MAG_USER_AGENT;
|
||||
const headers: Record<string, string> = {
|
||||
Cookie: cookieParts.join('; '),
|
||||
'User-Agent': magUserAgent,
|
||||
'X-User-Agent': STALKER_MAG_USER_AGENT,
|
||||
};
|
||||
|
||||
|
||||
@@ -166,6 +166,204 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {});
|
||||
});
|
||||
|
||||
it('serializes refreshAccountProfile behind an in-flight ensureToken', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-1',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
let releaseFirst: (value: { token: string }) => void = () => undefined;
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
token: 'profile-token',
|
||||
accountInfo: { login: 'user-1' },
|
||||
});
|
||||
|
||||
const pending = service.ensureToken(playlist);
|
||||
const refresh = service.refreshAccountProfile(playlist);
|
||||
|
||||
// Two handshakes must never overlap: on strict portals the second
|
||||
// would invalidate the first one's token.
|
||||
await Promise.resolve();
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseFirst({ token: 'session-token' });
|
||||
await pending;
|
||||
const accountInfo = await refresh;
|
||||
|
||||
expect(authenticate).toHaveBeenCalledTimes(2);
|
||||
expect(accountInfo).toEqual({ login: 'user-1' });
|
||||
// The refreshed token replaces the one its own handshake killed.
|
||||
expect(service.getCachedToken(playlist._id)).toBe('profile-token');
|
||||
});
|
||||
|
||||
it('lets only one of several queued refreshes authenticate at a time', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-3',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
const releases: Array<(value: { token: string }) => void> = [];
|
||||
const authenticate = jest
|
||||
.spyOn(service, 'authenticate')
|
||||
.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releases.push(resolve);
|
||||
})
|
||||
);
|
||||
|
||||
// Both refreshes queue behind the same in-flight ensureToken, so
|
||||
// one settled promise releases both waiters at once.
|
||||
const pending = service.ensureToken(playlist);
|
||||
const first = service.refreshAccountProfile(playlist);
|
||||
const second = service.refreshAccountProfile(playlist);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
|
||||
releases[0]({ token: 'session-token' });
|
||||
await pending;
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// The released waiters must not both start a handshake.
|
||||
expect(authenticate).toHaveBeenCalledTimes(2);
|
||||
|
||||
releases[1]({ token: 'first-refresh-token' });
|
||||
await first;
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
expect(authenticate).toHaveBeenCalledTimes(3);
|
||||
releases[2]({ token: 'second-refresh-token' });
|
||||
await second;
|
||||
|
||||
expect(service.getCachedToken(playlist._id)).toBe(
|
||||
'second-refresh-token'
|
||||
);
|
||||
});
|
||||
|
||||
it('retires the cached token before the refresh handshake starts', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-4',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
service.setCachedToken(playlist._id, 'stale-token');
|
||||
|
||||
let release: (value: { token: string }) => void = () => undefined;
|
||||
jest.spyOn(service, 'authenticate').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const refresh = service.refreshAccountProfile(playlist);
|
||||
await Promise.resolve();
|
||||
|
||||
// ensureToken() reads the cache before pendingAuth, so a token the
|
||||
// handshake is invalidating must not stay readable meanwhile.
|
||||
expect(service.getCachedToken(playlist._id)).toBeNull();
|
||||
|
||||
release({ token: 'fresh-token' });
|
||||
await refresh;
|
||||
expect(service.getCachedToken(playlist._id)).toBe('fresh-token');
|
||||
});
|
||||
|
||||
it('keeps a freshly refreshed token when a stale request fails auth late', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-5',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
// The request went out with the previous token; meanwhile a
|
||||
// profile refresh has already cached a fresh one.
|
||||
jest.spyOn(service, 'ensureToken')
|
||||
.mockResolvedValueOnce({ token: 'stale-token' })
|
||||
.mockResolvedValueOnce({ token: 'fresh-token' });
|
||||
service.setCachedToken(playlist._id, 'fresh-token');
|
||||
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: 'Authorization failed. 75' })
|
||||
.mockResolvedValueOnce({ js: { data: [] } });
|
||||
|
||||
await service.makeAuthenticatedRequest(playlist, {
|
||||
type: 'itv',
|
||||
action: 'get_ordered_list',
|
||||
});
|
||||
|
||||
// The late failure of the stale token must not delete the fresh
|
||||
// one — the retry reuses it instead of forcing a new handshake.
|
||||
expect(service.getCachedToken(playlist._id)).toBe('fresh-token');
|
||||
expect(dataService.sendIpcEvent).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ token: 'fresh-token' })
|
||||
);
|
||||
});
|
||||
|
||||
it('still retires the cached token when it is the one that failed', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-6',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
jest.spyOn(service, 'ensureToken')
|
||||
.mockResolvedValueOnce({ token: 'dead-token' })
|
||||
.mockResolvedValueOnce({ token: 'new-token' });
|
||||
service.setCachedToken(playlist._id, 'dead-token');
|
||||
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: 'Authorization failed. 75' })
|
||||
.mockResolvedValueOnce({ js: { data: [] } });
|
||||
|
||||
await service.makeAuthenticatedRequest(playlist, {
|
||||
type: 'itv',
|
||||
action: 'get_ordered_list',
|
||||
});
|
||||
|
||||
// Existing behavior preserved: the failed token itself is gone.
|
||||
expect(service.getCachedToken(playlist._id)).toBeNull();
|
||||
});
|
||||
|
||||
it('refreshes the account profile even when a pending authentication fails', async () => {
|
||||
const playlist = {
|
||||
_id: 'playlist-2',
|
||||
portalUrl,
|
||||
macAddress,
|
||||
isFullStalkerPortal: true,
|
||||
} as Playlist;
|
||||
|
||||
jest.spyOn(service, 'authenticate')
|
||||
.mockRejectedValueOnce(new Error('handshake refused'))
|
||||
.mockResolvedValueOnce({
|
||||
token: 'profile-token',
|
||||
accountInfo: { login: 'user-2' },
|
||||
});
|
||||
|
||||
const pending = service.ensureToken(playlist).catch(() => undefined);
|
||||
const accountInfo = await service.refreshAccountProfile(playlist);
|
||||
await pending;
|
||||
|
||||
expect(accountInfo).toEqual({ login: 'user-2' });
|
||||
});
|
||||
|
||||
it('passes an explicit serial into the initial handshake request', async () => {
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -520,6 +520,108 @@ export class StalkerSessionService {
|
||||
return authPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-runs handshake + get_profile to read the portal's current account
|
||||
* block, sharing `ensureToken()`'s per-playlist serialization.
|
||||
*
|
||||
* A handshake invalidates the previous token on strict portals, so two
|
||||
* overlapping ones leave whichever finishes first holding a dead token.
|
||||
* Waiting for any in-flight authentication (and registering this one so
|
||||
* later callers wait for it) keeps handshakes sequential; the resulting
|
||||
* token replaces the cached one, so catalog and playback requests keep
|
||||
* working afterwards.
|
||||
*/
|
||||
async refreshAccountProfile(
|
||||
playlist: Playlist
|
||||
): Promise<StalkerProfileResponse['js']['account_info']> {
|
||||
if (!playlist.portalUrl || !playlist.macAddress) {
|
||||
throw new Error('Portal URL and MAC address are required');
|
||||
}
|
||||
|
||||
const portalUrl = playlist.portalUrl;
|
||||
const macAddress = playlist.macAddress;
|
||||
const identity = getStalkerPortalIdentityFromPlaylist(playlist);
|
||||
|
||||
// Claim the per-playlist slot. Re-check after every await: one
|
||||
// settled promise releases every waiter at once, so a single
|
||||
// pre-check would let them all start competing handshakes.
|
||||
for (
|
||||
let inFlight = this.pendingAuth.get(playlist._id);
|
||||
inFlight;
|
||||
inFlight = this.pendingAuth.get(playlist._id)
|
||||
) {
|
||||
this.logger.debug('Waiting for pending authentication...');
|
||||
// A failed pending auth must not abort the refresh; this call
|
||||
// performs its own handshake either way.
|
||||
await inFlight.catch(() => undefined);
|
||||
}
|
||||
|
||||
// Publish the slot before the first await so no other waiter can
|
||||
// observe it as free while this handshake is starting.
|
||||
// No-op defaults: the executor runs synchronously and overwrites
|
||||
// both, but the compiler cannot prove that (TS2454).
|
||||
let settleSlot: (value: {
|
||||
token: string;
|
||||
serialNumber?: string;
|
||||
}) => void = () => undefined;
|
||||
let failSlot: (reason: unknown) => void = () => undefined;
|
||||
const slot = new Promise<{ token: string; serialNumber?: string }>(
|
||||
(resolve, reject) => {
|
||||
settleSlot = resolve;
|
||||
failSlot = reject;
|
||||
}
|
||||
);
|
||||
// Waiters attach their own handlers; this one only keeps a
|
||||
// rejected slot from surfacing as an unhandled rejection.
|
||||
void slot.catch(() => undefined);
|
||||
this.pendingAuth.set(playlist._id, slot);
|
||||
|
||||
// ensureToken() reads tokenCache before pendingAuth, so leaving the
|
||||
// old token there would hand a token this handshake is about to
|
||||
// invalidate to catalog and watchdog requests. Retiring it first
|
||||
// makes them queue on the slot instead.
|
||||
this.clearCachedToken(playlist._id);
|
||||
|
||||
try {
|
||||
const result = await this.authenticate(
|
||||
portalUrl,
|
||||
macAddress,
|
||||
identity
|
||||
);
|
||||
this.setCachedToken(playlist._id, result.token);
|
||||
settleSlot({
|
||||
token: result.token,
|
||||
serialNumber: identity.serialNumber,
|
||||
});
|
||||
return result.accountInfo;
|
||||
} catch (error) {
|
||||
failSlot(error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Only retire our own entry: a caller that started a later
|
||||
// authentication owns the map slot from then on.
|
||||
if (this.pendingAuth.get(playlist._id) === slot) {
|
||||
this.pendingAuth.delete(playlist._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached token only while it is still the one that just
|
||||
* failed. A request dispatched with the previous token can see its
|
||||
* authorization failure arrive after a profile refresh has already
|
||||
* cached a fresh token — deleting blindly would kill that fresh token
|
||||
* and trigger a cascade of competing handshakes on strict portals.
|
||||
*/
|
||||
private retireFailedToken(
|
||||
playlistId: string,
|
||||
failedToken: string | null
|
||||
): void {
|
||||
if (failedToken && this.getCachedToken(playlistId) === failedToken) {
|
||||
this.clearCachedToken(playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a response or error indicates an authorization failure
|
||||
*/
|
||||
@@ -581,8 +683,8 @@ export class StalkerSessionService {
|
||||
// Check for authorization failure in response
|
||||
if (this.isAuthorizationError(response)) {
|
||||
if (retryOnAuthFailure && playlist.isFullStalkerPortal) {
|
||||
// Clear cached token to force re-authentication
|
||||
this.clearCachedToken(playlist._id);
|
||||
// Retire the failed token so the retry re-authenticates
|
||||
this.retireFailedToken(playlist._id, token);
|
||||
// Retry once with fresh authentication
|
||||
return this.makeAuthenticatedRequest<T>(
|
||||
playlist,
|
||||
@@ -602,8 +704,8 @@ export class StalkerSessionService {
|
||||
retryOnAuthFailure &&
|
||||
playlist.isFullStalkerPortal
|
||||
) {
|
||||
// Clear cached token and retry with new handshake
|
||||
this.clearCachedToken(playlist._id);
|
||||
// Retire the failed token and retry with new handshake
|
||||
this.retireFailedToken(playlist._id, token);
|
||||
return this.makeAuthenticatedRequest<T>(
|
||||
playlist,
|
||||
params,
|
||||
|
||||
+83
@@ -284,4 +284,87 @@ describe('withStalkerPlayer', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches the portal header set to VOD playback on the portal host', async () => {
|
||||
const session = TestBed.inject(StalkerSessionService) as unknown as {
|
||||
getCachedToken: jest.Mock;
|
||||
};
|
||||
session.getCachedToken.mockReturnValue('TOKEN99');
|
||||
// Same host as the portal, different port — the #1158-class stream
|
||||
// shape that is gated on the portal cookie.
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: { data: [{ id: 77 }] } })
|
||||
.mockResolvedValueOnce({
|
||||
js: { cmd: 'ffmpeg http://demo.example:8080/video_77.mpg' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveVodPlayback(
|
||||
undefined,
|
||||
'Movie Title',
|
||||
'thumb.jpg'
|
||||
);
|
||||
|
||||
expect(session.getCachedToken).toHaveBeenCalledWith(PLAYLIST._id);
|
||||
expect(playback.headers?.['Cookie']).toContain(
|
||||
'mac=00:1A:79:00:00:01'
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN99');
|
||||
expect(playback.headers?.['X-User-Agent']).toBeDefined();
|
||||
expect(playback.userAgent).toBe(playback.headers?.['User-Agent']);
|
||||
expect(playback.referer).toBe('http://demo.example');
|
||||
expect(playback.origin).toBe('http://demo.example');
|
||||
});
|
||||
|
||||
it('keeps VOD playback from a foreign CDN credential-free', async () => {
|
||||
const session = TestBed.inject(StalkerSessionService) as unknown as {
|
||||
getCachedToken: jest.Mock;
|
||||
};
|
||||
session.getCachedToken.mockReturnValue('TOKEN99');
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: { data: [{ id: 77 }] } })
|
||||
.mockResolvedValueOnce({
|
||||
js: { cmd: 'ffmpeg http://cdn.example/video_77.mpg' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveVodPlayback(
|
||||
undefined,
|
||||
'Movie Title',
|
||||
'thumb.jpg'
|
||||
);
|
||||
|
||||
expect(playback.headers?.['Cookie']).toBeUndefined();
|
||||
expect(playback.headers?.['Authorization']).toBeUndefined();
|
||||
expect(playback.headers?.['User-Agent']).toBe('KSPlayer');
|
||||
expect(playback.referer).toBeUndefined();
|
||||
expect(playback.origin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attaches the portal header set to radio playback resolved from the portal', async () => {
|
||||
const session = TestBed.inject(StalkerSessionService) as unknown as {
|
||||
getCachedToken: jest.Mock;
|
||||
};
|
||||
session.getCachedToken.mockReturnValue('TOKEN99');
|
||||
store.setSelectedContentType('radio');
|
||||
const radioItem = {
|
||||
id: 'radio-2',
|
||||
cmd: '/media/radio_2.mpg',
|
||||
name: 'Portal FM',
|
||||
o_name: 'Portal FM',
|
||||
logo: 'portal-fm.png',
|
||||
category_id: '4001',
|
||||
};
|
||||
store.setSelectedItem(radioItem);
|
||||
dataService.sendIpcEvent.mockResolvedValueOnce({
|
||||
js: { cmd: 'ffmpeg http://demo.example/radio_2.mpg' },
|
||||
});
|
||||
|
||||
const playback = await store.resolveRadioPlayback(radioItem);
|
||||
|
||||
expect(playback.headers?.['Cookie']).toContain(
|
||||
'mac=00:1A:79:00:00:01'
|
||||
);
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN99');
|
||||
expect(playback.referer).toBe('http://demo.example');
|
||||
expect(playback.origin).toBe('http://demo.example');
|
||||
});
|
||||
});
|
||||
+53
-6
@@ -196,14 +196,38 @@ export function withStalkerPlayer() {
|
||||
const selectedItemId =
|
||||
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
|
||||
|
||||
// VOD and series streams sit behind the same portal gate
|
||||
// as ITV: without the mac cookie/Bearer token an
|
||||
// auth-enforcing portal answers 403 (they previously
|
||||
// carried no portal headers at all — audit finding 5).
|
||||
const token = stalkerSession.getCachedToken(playlist._id);
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
playlist,
|
||||
token,
|
||||
streamUrl
|
||||
);
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
playlist,
|
||||
streamUrl
|
||||
);
|
||||
const portalOrigin = getStalkerPortalOrigin(playlist);
|
||||
|
||||
return {
|
||||
streamUrl,
|
||||
title: title ?? '',
|
||||
thumbnail,
|
||||
startTime,
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
headers,
|
||||
userAgent:
|
||||
headers['User-Agent'] ||
|
||||
playlist.userAgent ||
|
||||
STALKER_MAG_USER_AGENT,
|
||||
referer: crossOriginStream
|
||||
? undefined
|
||||
: playlist.referrer || portalOrigin,
|
||||
origin: crossOriginStream
|
||||
? undefined
|
||||
: playlist.origin || portalOrigin,
|
||||
contentInfo: {
|
||||
playlistId: playlist._id,
|
||||
contentXtreamId:
|
||||
@@ -319,13 +343,36 @@ export function withStalkerPlayer() {
|
||||
item.o_name || item.name || item.title
|
||||
);
|
||||
|
||||
// Radio streams come off the same portal as ITV and are
|
||||
// gated the same way — give them the identical header set
|
||||
// (they previously carried no portal headers at all).
|
||||
const token = stalkerSession.getCachedToken(playlist._id);
|
||||
const headers = buildStalkerExternalPlaybackHeaders(
|
||||
playlist,
|
||||
token,
|
||||
streamUrl
|
||||
);
|
||||
const crossOriginStream = isCrossOriginStalkerStream(
|
||||
playlist,
|
||||
streamUrl
|
||||
);
|
||||
const portalOrigin = getStalkerPortalOrigin(playlist);
|
||||
|
||||
return {
|
||||
streamUrl,
|
||||
title: item.o_name || item.name || item.title || '',
|
||||
thumbnail: item.logo ?? item.cover ?? null,
|
||||
userAgent: playlist.userAgent,
|
||||
referer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
headers,
|
||||
userAgent:
|
||||
headers['User-Agent'] ||
|
||||
playlist.userAgent ||
|
||||
STALKER_MAG_USER_AGENT,
|
||||
referer: crossOriginStream
|
||||
? undefined
|
||||
: playlist.referrer || portalOrigin,
|
||||
origin: crossOriginStream
|
||||
? undefined
|
||||
: playlist.origin || portalOrigin,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (isFullPortal) {
|
||||
<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 (isFullPortal) {
|
||||
{{
|
||||
'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>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
@use '../../../../../../ui/styles/account-dialog';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
--account-dialog-accent: var(
|
||||
--app-provider-stalker,
|
||||
var(--mat-sys-primary)
|
||||
);
|
||||
}
|
||||
|
||||
// Stalker shows three hero stats (days left / tariff / MAC), not four.
|
||||
.account-dialog__hero-stats {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.account-dialog__hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { StalkerAccountInfoService } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerAccountInfoComponent } from './stalker-account-info.component';
|
||||
|
||||
describe('StalkerAccountInfoComponent', () => {
|
||||
let fixture: ComponentFixture<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',
|
||||
// floor, not round: rounding the epoch up puts the expiry a few
|
||||
// hundred ms past the 30-day mark and ceil() then yields 31.
|
||||
expireDate: Math.floor(Date.now() / 1000) + 30 * 86_400,
|
||||
tariffPlanName: 'Premium',
|
||||
status: 1,
|
||||
};
|
||||
|
||||
const cachedSnapshot = {
|
||||
login: 'cached-user',
|
||||
expireDate: Math.floor(Date.now() / 1000) + 3 * 86_400,
|
||||
tariffPlanName: 'Basic',
|
||||
status: 1,
|
||||
};
|
||||
|
||||
async function createComponent(): Promise<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('normalizes stringly-typed cached values persisted at import time', async () => {
|
||||
accountInfoService.fetchAccountInfo.mockRejectedValue(
|
||||
new Error('offline')
|
||||
);
|
||||
playlistsService.getPlaylist.mockReturnValue(
|
||||
of({
|
||||
stalkerAccountInfo: {
|
||||
login: 'cached-user',
|
||||
// Import persists portal values verbatim; a date string
|
||||
// must not blow up daysLeft/expiry rendering.
|
||||
expireDate: '2099-12-31',
|
||||
status: '1',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await createComponent();
|
||||
|
||||
expect(component.loadState()).toBe('ready');
|
||||
expect(component.statusKind()).toBe('active');
|
||||
expect(component.expiryDate()?.getFullYear()).toBe(2099);
|
||||
expect(component.daysLeft()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps the cached snapshot when the portal publishes nothing', async () => {
|
||||
accountInfoService.fetchAccountInfo.mockResolvedValue(null);
|
||||
playlistsService.getPlaylist.mockReturnValue(
|
||||
of({ stalkerAccountInfo: cachedSnapshot })
|
||||
);
|
||||
|
||||
await createComponent();
|
||||
|
||||
expect(component.loadState()).toBe('ready');
|
||||
expect(component.snapshot()).toEqual(cachedSnapshot);
|
||||
expect(component.snapshotSource()).toBe('cached');
|
||||
expect(component.refreshFailed()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the error state when there is no data at all', async () => {
|
||||
accountInfoService.fetchAccountInfo.mockRejectedValue(
|
||||
new Error('offline')
|
||||
);
|
||||
|
||||
await createComponent();
|
||||
|
||||
expect(component.loadState()).toBe('error');
|
||||
expect(component.snapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it('stays ready with an empty account panel when the portal is reachable but silent', async () => {
|
||||
accountInfoService.fetchAccountInfo.mockResolvedValue(null);
|
||||
|
||||
await createComponent();
|
||||
|
||||
expect(component.loadState()).toBe('ready');
|
||||
expect(component.snapshot()).toBeNull();
|
||||
expect(component.accountDetails()).toEqual([]);
|
||||
// Portal facts are always known, even without account data.
|
||||
expect(component.portalDetails().map((row) => row.value)).toEqual([
|
||||
playlist.portalUrl,
|
||||
playlist.macAddress,
|
||||
]);
|
||||
});
|
||||
|
||||
it('survives a broken cache read and still shows fresh data', async () => {
|
||||
playlistsService.getPlaylist.mockReturnValue(
|
||||
throwError(() => new Error('db locked'))
|
||||
);
|
||||
|
||||
await createComponent();
|
||||
|
||||
expect(component.loadState()).toBe('ready');
|
||||
expect(component.snapshot()).toEqual(freshSnapshot);
|
||||
expect(component.snapshotSource()).toBe('fresh');
|
||||
});
|
||||
|
||||
it('reports an expiry that passed hours ago as expired, not "0 days left"', async () => {
|
||||
accountInfoService.fetchAccountInfo.mockResolvedValue({
|
||||
...freshSnapshot,
|
||||
expireDate: Math.floor(Date.now() / 1000) - 3600,
|
||||
});
|
||||
|
||||
await createComponent();
|
||||
|
||||
// Math.ceil of a fraction of a day rounds up to 0/-0, so the
|
||||
// counter must not decide expiry.
|
||||
expect(component.daysLeft()).toBe(-0);
|
||||
expect(component.hasExpired()).toBe(true);
|
||||
expect(component.heroStats()[0].labelKey).toBe(
|
||||
'STALKER.ACCOUNT_INFO.EXPIRED'
|
||||
);
|
||||
});
|
||||
|
||||
it('labels a restored full portal by URL when the flag is absent', async () => {
|
||||
await TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
StalkerAccountInfoComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: {
|
||||
playlist: {
|
||||
...playlist,
|
||||
portalUrl:
|
||||
'http://portal.example/stalker_portal/c/',
|
||||
isFullStalkerPortal: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StalkerAccountInfoService,
|
||||
useValue: accountInfoService,
|
||||
},
|
||||
{ provide: PlaylistsService, useValue: playlistsService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const restored = TestBed.createComponent(StalkerAccountInfoComponent);
|
||||
restored.detectChanges();
|
||||
await restored.whenStable();
|
||||
|
||||
// Presentation must agree with the fetch path, which resolves the
|
||||
// portal type from the URL when the flag is missing.
|
||||
expect(restored.componentInstance.isFullPortal).toBe(true);
|
||||
expect(restored.nativeElement.textContent).toContain(
|
||||
'STALKER.ACCOUNT_INFO.PORTAL_TYPE_FULL'
|
||||
);
|
||||
expect(restored.nativeElement.textContent).not.toContain(
|
||||
'STALKER.ACCOUNT_INFO.PORTAL_TYPE_LEGACY'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders no status pill for unknown status values', async () => {
|
||||
accountInfoService.fetchAccountInfo.mockResolvedValue({
|
||||
...freshSnapshot,
|
||||
status: 7,
|
||||
});
|
||||
|
||||
await createComponent();
|
||||
|
||||
expect(component.statusKind()).toBeNull();
|
||||
expect(
|
||||
component
|
||||
.accountDetails()
|
||||
.find((row) => row.labelKey === 'STALKER.ACCOUNT_INFO.STATUS')
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
isFullStalkerPortalPlaylist,
|
||||
normalizeStoredStalkerAccountInfo,
|
||||
StalkerAccountInfoService,
|
||||
StalkerAccountSnapshot,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
type StalkerAccountInfoDialogData,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
type AccountLoadState = 'loading' | 'ready' | 'error';
|
||||
|
||||
/** Where the currently displayed snapshot came from. */
|
||||
type SnapshotSource = 'fresh' | 'cached';
|
||||
|
||||
interface AccountDetailRow {
|
||||
labelKey: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
tone?: 'positive' | 'warning';
|
||||
}
|
||||
|
||||
interface AccountStat {
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
value: string;
|
||||
warning?: boolean;
|
||||
}
|
||||
|
||||
const DAY_IN_MS = 86_400_000;
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-account-info',
|
||||
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
|
||||
templateUrl: './stalker-account-info.component.html',
|
||||
styleUrl: './stalker-account-info.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StalkerAccountInfoComponent {
|
||||
private readonly data = inject<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;
|
||||
/**
|
||||
* Presentation must use the same resolution as the fetch path: a
|
||||
* playlist restored from an older backup carries no explicit flag,
|
||||
* and the raw field would label a real Ministra portal as legacy.
|
||||
*/
|
||||
readonly isFullPortal = isFullStalkerPortalPlaylist(this.playlist);
|
||||
readonly loadState = signal<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);
|
||||
});
|
||||
/**
|
||||
* Read from the raw timestamp, not daysLeft: an expiry that passed
|
||||
* less than 24h ago rounds up to 0 (or -0), which is not negative, so
|
||||
* the counter would claim "0 days left" on a dead subscription.
|
||||
*/
|
||||
readonly hasExpired = computed(() => {
|
||||
const expiry = this.expiryDate();
|
||||
return expiry ? expiry.getTime() <= Date.now() : false;
|
||||
});
|
||||
readonly expiresSoon = computed(() => {
|
||||
const daysLeft = this.daysLeft();
|
||||
return daysLeft !== null && daysLeft <= 7;
|
||||
});
|
||||
|
||||
readonly heroStats = computed<AccountStat[]>(() => {
|
||||
const stats: AccountStat[] = [];
|
||||
const daysLeft = this.daysLeft();
|
||||
|
||||
if (daysLeft !== null) {
|
||||
const expired = this.hasExpired();
|
||||
stats.push({
|
||||
icon: 'timer',
|
||||
labelKey: expired
|
||||
? 'STALKER.ACCOUNT_INFO.EXPIRED'
|
||||
: 'STALKER.ACCOUNT_INFO.DAYS_LEFT',
|
||||
value: expired
|
||||
? this.formatDate(this.expiryDate())
|
||||
: String(daysLeft),
|
||||
warning: this.expiresSoon(),
|
||||
});
|
||||
}
|
||||
|
||||
const tariff = this.snapshot()?.tariffPlanName;
|
||||
if (tariff) {
|
||||
stats.push({
|
||||
icon: 'loyalty',
|
||||
labelKey: 'STALKER.ACCOUNT_INFO.TARIFF_PLAN',
|
||||
value: tariff,
|
||||
});
|
||||
}
|
||||
|
||||
stats.push({
|
||||
icon: 'router',
|
||||
labelKey: 'STALKER.ACCOUNT_INFO.MAC_ADDRESS',
|
||||
value: this.snapshot()?.mac || this.playlist.macAddress || '-',
|
||||
});
|
||||
|
||||
return stats;
|
||||
});
|
||||
|
||||
readonly accountDetails = computed<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)
|
||||
);
|
||||
// The import path persists portal values verbatim, so the
|
||||
// stored expiry may be a date string or milliseconds despite
|
||||
// the declared type — normalize before rendering.
|
||||
const cached = normalizeStoredStalkerAccountInfo(
|
||||
playlist?.stalkerAccountInfo
|
||||
);
|
||||
if (cached) {
|
||||
this.snapshot.set(cached);
|
||||
this.snapshotSource.set('cached');
|
||||
this.loadState.set('ready');
|
||||
}
|
||||
} catch (error) {
|
||||
// Cache miss is non-fatal — the fresh fetch decides the state.
|
||||
this.logger.warn('Failed to read cached account info', error);
|
||||
}
|
||||
}
|
||||
|
||||
private formatDate(date: Date | null): string {
|
||||
return date ? date.toLocaleDateString() : '-';
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -12,6 +12,11 @@
|
||||
|
||||
.category-content-header {
|
||||
@include panel.standard-panel-header($sticky: true);
|
||||
|
||||
// Same reason as the Xtream live header: the paginator does not shrink,
|
||||
// so it must be able to drop to its own line rather than crowd the meta
|
||||
// out of existence.
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
@@ -49,7 +54,9 @@
|
||||
// painting its own (mismatched) Material surface color.
|
||||
mat-paginator {
|
||||
background: transparent !important;
|
||||
flex-shrink: 0;
|
||||
// Its own container already wraps internally, so letting it shrink turns
|
||||
// a clipped next-page arrow into a second line of the paginator itself.
|
||||
min-width: 0;
|
||||
--mat-paginator-container-size: 40px;
|
||||
--mat-paginator-enabled-icon-color: var(
|
||||
--app-body-color,
|
||||
@@ -70,3 +77,24 @@ app-grid-list.all-items-grid {
|
||||
scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent)
|
||||
transparent;
|
||||
}
|
||||
|
||||
// Mirrors the Xtream live header: on a phone the page-size select is the
|
||||
// widest part of the paginator and the least useful one, and the count it
|
||||
// costs is already stated by the range beside it.
|
||||
@media (max-width: 640px) {
|
||||
.category-content-header {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.category-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
min-width: 0;
|
||||
|
||||
::ng-deep .mat-mdc-paginator-page-size {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -279,6 +279,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
window.electron = {
|
||||
platform: 'darwin',
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
updateRemoteControlStatus: jest.fn(),
|
||||
onChannelChange: jest.fn(() => jest.fn()),
|
||||
onRemoteControlCommand: jest.fn(() => jest.fn()),
|
||||
@@ -1127,6 +1128,121 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
expect(audioPlayer.channelName()).toBe('Jazz FM');
|
||||
expect(audioPlayer.dispatchAdjacentChannelAction()).toBe(false);
|
||||
});
|
||||
|
||||
it('configures the scoped Electron header override before radio playback starts', async () => {
|
||||
// The radio branch renders the dedicated audio player, not
|
||||
// WebPlayerViewComponent — without this wiring an auth-gated portal
|
||||
// radio stream 403s because its credentials never reach the request.
|
||||
stalkerStore.selectedContentType.set('radio');
|
||||
selectedCategoryId.set('radio-all');
|
||||
selectedItem.set(null);
|
||||
selectedItvId.set(undefined);
|
||||
fixture.detectChanges();
|
||||
resolveRadioPlayback.mockResolvedValue({
|
||||
streamUrl: 'http://portal.example/radio_2.mpg',
|
||||
title: 'Portal FM',
|
||||
headers: {
|
||||
'User-Agent': 'MAG250',
|
||||
Referer: 'http://portal.example',
|
||||
Cookie: 'mac=00:1A:79:00:00:01',
|
||||
Authorization: 'Bearer TOKEN99',
|
||||
},
|
||||
});
|
||||
|
||||
await component.playChannel(radioChannels()[0]);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(window.electron?.setUserAgent).toHaveBeenCalledWith(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
'http://portal.example/radio_2.mpg',
|
||||
{
|
||||
authorization: 'Bearer TOKEN99',
|
||||
cookie: 'mac=00:1A:79:00:00:01',
|
||||
}
|
||||
);
|
||||
|
||||
const audioPlayer = fixture.debugElement.query(
|
||||
By.directive(StubAudioPlayerComponent)
|
||||
).componentInstance as StubAudioPlayerComponent;
|
||||
expect(audioPlayer.url()).toBe('http://portal.example/radio_2.mpg');
|
||||
});
|
||||
|
||||
it('clears the radio header override when destroyed while the apply IPC is pending', async () => {
|
||||
// Leaving the radio route mid-apply must not leave the portal
|
||||
// cookie/token installed: ownership is claimed before awaiting the
|
||||
// IPC, so ngOnDestroy can always name the stream to clear.
|
||||
stalkerStore.selectedContentType.set('radio');
|
||||
selectedCategoryId.set('radio-all');
|
||||
selectedItem.set(null);
|
||||
selectedItvId.set(undefined);
|
||||
fixture.detectChanges();
|
||||
let resolveApply: (() => void) | undefined;
|
||||
let signalApplyIssued!: () => void;
|
||||
const applyIssued = new Promise<void>(
|
||||
(resolve) => (signalApplyIssued = resolve)
|
||||
);
|
||||
// Once: only the apply call gets the pending promise — the destroy
|
||||
// clear below also calls the bridge and must not steal the resolver.
|
||||
(
|
||||
window.electron?.setUserAgent as jest.Mock
|
||||
).mockImplementationOnce(() => {
|
||||
signalApplyIssued();
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveApply = () => resolve(true);
|
||||
});
|
||||
});
|
||||
resolveRadioPlayback.mockResolvedValue({
|
||||
streamUrl: 'http://portal.example/radio_2.mpg',
|
||||
title: 'Portal FM',
|
||||
headers: { Cookie: 'mac=00:1A:79:00:00:01' },
|
||||
});
|
||||
|
||||
const playPromise = component.playChannel(radioChannels()[0]);
|
||||
// The header IPC has been issued but is still pending.
|
||||
await applyIssued;
|
||||
|
||||
fixture.destroy();
|
||||
resolveApply?.();
|
||||
await playPromise;
|
||||
|
||||
expect(window.electron?.setUserAgent).toHaveBeenLastCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
'http://portal.example/radio_2.mpg'
|
||||
);
|
||||
expect(component.activePlayback()).toBeNull();
|
||||
});
|
||||
|
||||
it('releases the radio override when the next selection never mounts a player', async () => {
|
||||
// Switching from a playing radio to a channel whose resolution fails
|
||||
// (or plays externally) mounts no player surface — the selection
|
||||
// itself must release the previously installed credentials.
|
||||
stalkerStore.selectedContentType.set('radio');
|
||||
selectedCategoryId.set('radio-all');
|
||||
selectedItem.set(null);
|
||||
selectedItvId.set(undefined);
|
||||
fixture.detectChanges();
|
||||
resolveRadioPlayback.mockResolvedValue({
|
||||
streamUrl: 'http://portal.example/radio_2.mpg',
|
||||
title: 'Portal FM',
|
||||
headers: { Cookie: 'mac=00:1A:79:00:00:01' },
|
||||
});
|
||||
await component.playChannel(radioChannels()[0]);
|
||||
await fixture.whenStable();
|
||||
|
||||
stalkerStore.selectedContentType.set('itv');
|
||||
resolveItvPlayback.mockRejectedValueOnce(new Error('portal down'));
|
||||
await component.playChannel(itvChannels()[0]);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(window.electron?.setUserAgent).toHaveBeenLastCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
'http://portal.example/radio_2.mpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function buildProgram(channelId: string, title: string): EpgProgram {
|
||||
|
||||
+41
-1
@@ -51,6 +51,7 @@ import {
|
||||
} from '@iptvnator/ui/epg';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ElectronStreamHeadersService,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
@@ -111,11 +112,13 @@ const FULL_LIST_RENDER_CHUNK = 100;
|
||||
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly streamHeaders = inject(ElectronStreamHeadersService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly liveSidebarStateService = inject(
|
||||
@@ -360,6 +363,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
private epgLoadRequestId = 0;
|
||||
private playbackRequestId = 0;
|
||||
/** Stream URL of the radio playback whose header override this layout configured. */
|
||||
private radioHeaderScopeUrl: string | null = null;
|
||||
private playbackResolution: {
|
||||
channelId: string;
|
||||
promise: Promise<ResolvedPortalPlayback>;
|
||||
@@ -556,6 +561,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
this.unsubscribeRemoteChannelChange?.();
|
||||
this.unsubscribeRemoteCommand?.();
|
||||
this.removeScrollListener();
|
||||
// Invalidate any playback continuation still awaiting its header
|
||||
// IPC, then drop the radio credentials — they must not outlive this
|
||||
// layout. The service no-ops when a newer playback already owns the
|
||||
// override slot.
|
||||
this.playbackRequestId += 1;
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
}
|
||||
|
||||
isSelectedChannel(item: StalkerItvChannel): boolean {
|
||||
@@ -572,6 +583,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
const channelId = normalizeStalkerEntityId(item.id);
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
this.ensureChannelWithinRenderWindow(channelId);
|
||||
// A previously owned radio override must not survive into a
|
||||
// selection that never mounts a player surface of its own — external
|
||||
// video playback and failed resolutions would otherwise keep the old
|
||||
// radio credentials installed for that origin.
|
||||
this.streamHeaders.clear(this.radioHeaderScopeUrl);
|
||||
this.radioHeaderScopeUrl = null;
|
||||
|
||||
try {
|
||||
const isRadioMode = this.isRadioMode();
|
||||
@@ -587,6 +604,25 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
if (isRadioMode) {
|
||||
// The radio branch renders the dedicated audio player, not
|
||||
// WebPlayerViewComponent, so the scoped Electron header
|
||||
// override (portal cookie/token for auth-gated streams) must
|
||||
// be configured here BEFORE the audio element gets the URL.
|
||||
// Ownership is claimed synchronously, before awaiting the
|
||||
// IPC: if this layout is destroyed while the apply is still
|
||||
// in flight, ngOnDestroy must already know which stream's
|
||||
// override to clear — otherwise the credentials would
|
||||
// outlive the route.
|
||||
const headerSync = this.streamHeaders.apply(playback);
|
||||
this.radioHeaderScopeUrl = playback.streamUrl;
|
||||
const stillCurrent = headerSync ? await headerSync : true;
|
||||
if (
|
||||
!stillCurrent ||
|
||||
requestId !== this.playbackRequestId ||
|
||||
this.selectedChannelId() !== channelId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.activePlayback.set(playback);
|
||||
return;
|
||||
}
|
||||
@@ -727,7 +763,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.key.toLowerCase() === 'b' &&
|
||||
!isTypingInInput(event)
|
||||
!isTypingInInput(event) &&
|
||||
// Behind the workspace's phone context drawer the route content
|
||||
// is inert; this document-level listener still fires, so it
|
||||
// opts out itself instead of toggling an obscured sidebar.
|
||||
!this.hostElement.nativeElement.closest('[inert]')
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.toggleSidebar();
|
||||
|
||||
+43
@@ -88,11 +88,32 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
|
||||
);
|
||||
expect(result).toEqual([dbCategory]);
|
||||
expect(onPhaseChange.mock.calls).toEqual([
|
||||
['loading-cached'],
|
||||
['loading-categories'],
|
||||
['saving-categories'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports the loading-cached phase when categories come from the DB', async () => {
|
||||
harness.dbService.getXtreamImportStatus.mockResolvedValue(
|
||||
'completed'
|
||||
);
|
||||
harness.dbService.getXtreamCategories.mockResolvedValue([
|
||||
dbCategory,
|
||||
]);
|
||||
const onPhaseChange = jest.fn();
|
||||
|
||||
await harness.dataSource.getCategories(
|
||||
playlistId,
|
||||
credentials,
|
||||
'live',
|
||||
{ onPhaseChange }
|
||||
);
|
||||
|
||||
expect(onPhaseChange.mock.calls).toEqual([['loading-cached']]);
|
||||
expect(harness.apiService.getCategories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refetches from the API when cached rows exist but the import never completed', async () => {
|
||||
harness.dbService.getXtreamImportStatus.mockResolvedValue(
|
||||
'importing'
|
||||
@@ -217,6 +238,28 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
|
||||
expect(harness.apiService.getStreams).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the loading-cached phase when content comes from the DB', async () => {
|
||||
harness.dbService.getXtreamImportStatus.mockResolvedValue(
|
||||
'completed'
|
||||
);
|
||||
harness.dbService.getXtreamContent.mockResolvedValue([
|
||||
dbContentItem,
|
||||
]);
|
||||
const onPhaseChange = jest.fn();
|
||||
|
||||
await harness.dataSource.getContent(
|
||||
playlistId,
|
||||
credentials,
|
||||
'live',
|
||||
undefined,
|
||||
undefined,
|
||||
{ onPhaseChange }
|
||||
);
|
||||
|
||||
expect(onPhaseChange.mock.calls).toEqual([['loading-cached']]);
|
||||
expect(harness.apiService.getStreams).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches from the API, reports progress, and caches on a cold cache', async () => {
|
||||
const remoteStreams = [
|
||||
{ stream_id: 101, name: 'News Live' },
|
||||
|
||||
@@ -157,6 +157,9 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
options?: XtreamOperationOptions
|
||||
): Promise<XtreamCategoryFromDb[]> {
|
||||
const importType = this.mapCategoryTypeToImportType(type);
|
||||
// The DB read below is the slow part of a warm start; report it as a
|
||||
// phase so the sync overlay never shows a phaseless card.
|
||||
options?.onPhaseChange?.('loading-cached');
|
||||
const importStatus = await this.getImportStatus(playlistId, importType);
|
||||
// Fetch from DB directly — avoids a separate 'has' round-trip.
|
||||
// An empty result means the cache is cold; proceed to fetch from API.
|
||||
@@ -299,6 +302,9 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
onTotal?: (total: number) => void,
|
||||
options?: XtreamOperationOptions
|
||||
): Promise<XtreamContentItem[]> {
|
||||
// The DB read below is the slow part of a warm start; report it as a
|
||||
// phase so the sync overlay never shows a phaseless card.
|
||||
options?.onPhaseChange?.('loading-cached');
|
||||
const importStatus = await this.getImportStatus(playlistId, type);
|
||||
// Fetch from DB directly — avoids a separate 'has' round-trip.
|
||||
// An empty result means the cache is cold; proceed to fetch from API.
|
||||
|
||||
@@ -53,6 +53,37 @@ describe('PwaXtreamDataSource', () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('reports remote loading phases for API fetches but stays silent on cache hits', async () => {
|
||||
apiService.getStreams.mockResolvedValue([
|
||||
{ stream_id: 1, name: 'News' },
|
||||
]);
|
||||
const onPhaseChange = jest.fn();
|
||||
|
||||
await dataSource.getContent(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie',
|
||||
undefined,
|
||||
undefined,
|
||||
{ onPhaseChange }
|
||||
);
|
||||
|
||||
expect(onPhaseChange.mock.calls).toEqual([['loading-movies']]);
|
||||
|
||||
onPhaseChange.mockClear();
|
||||
await dataSource.getContent(
|
||||
'playlist-1',
|
||||
credentials,
|
||||
'movie',
|
||||
undefined,
|
||||
undefined,
|
||||
{ onPhaseChange }
|
||||
);
|
||||
|
||||
expect(onPhaseChange).not.toHaveBeenCalled();
|
||||
expect(apiService.getStreams).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps Xtream passwords out of localStorage playlist metadata', async () => {
|
||||
await dataSource.createPlaylist({
|
||||
id: 'playlist-1',
|
||||
|
||||
@@ -296,7 +296,6 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
type: CategoryType,
|
||||
options?: XtreamOperationOptions
|
||||
): Promise<XtreamCategory[]> {
|
||||
void options;
|
||||
const cacheKey = `${playlistId}-${type}-categories`;
|
||||
|
||||
// Check in-memory cache first
|
||||
@@ -306,6 +305,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
options?.onPhaseChange?.('loading-categories');
|
||||
const categories = await this.apiService.getCategories(
|
||||
credentials,
|
||||
type
|
||||
@@ -381,7 +381,6 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
| XtreamSerieItem[]
|
||||
| XtreamContentItem[]
|
||||
> {
|
||||
void options;
|
||||
const cacheKey = `${playlistId}-${type}-content`;
|
||||
|
||||
// Check in-memory cache first
|
||||
@@ -394,6 +393,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
options?.onPhaseChange?.(
|
||||
type === 'live'
|
||||
? 'loading-live'
|
||||
: type === 'movie'
|
||||
? 'loading-movies'
|
||||
: 'loading-series'
|
||||
);
|
||||
const content = this.normalizeContentItems(
|
||||
await this.apiService.getStreams(credentials, type),
|
||||
type
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
DatabaseService,
|
||||
XtreamPendingRestoreService,
|
||||
} from '@iptvnator/services';
|
||||
import { XTREAM_DATA_SOURCE } from '../../data-sources/xtream-data-source.interface';
|
||||
import { XtreamApiService } from '../../services/xtream-api.service';
|
||||
import { PortalStatusType } from '../../xtream-state';
|
||||
import {
|
||||
createAbortError,
|
||||
createContentTestStore,
|
||||
createDeferred,
|
||||
createPendingRestoreServiceMock,
|
||||
waitForCondition,
|
||||
} from './with-content.feature.spec-helpers';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
type ContentType = 'live' | 'movie' | 'series';
|
||||
|
||||
let checkPortalStatusMock: jest.Mock<Promise<PortalStatusType>, []>;
|
||||
|
||||
const TestContentStore = createContentTestStore(() => checkPortalStatusMock());
|
||||
|
||||
type PhaseOptions = { onPhaseChange?: (phase: string) => void };
|
||||
|
||||
// The 'loading-cached' phase is presentation-only: it drives the sync overlay
|
||||
// labels but must never mark a real import as started, because the error path
|
||||
// gates import-cache cleanup on isImporting.
|
||||
describe('withContent loading-cached phase', () => {
|
||||
let store: InstanceType<typeof TestContentStore>;
|
||||
let dataSource: {
|
||||
getCategories: jest.Mock;
|
||||
getCachedCategories: jest.Mock;
|
||||
getContent: jest.Mock;
|
||||
getCachedContent: jest.Mock;
|
||||
hasCategories: jest.Mock;
|
||||
hasContent: jest.Mock;
|
||||
restoreUserData: jest.Mock;
|
||||
};
|
||||
let databaseService: {
|
||||
clearXtreamImportCache: jest.Mock;
|
||||
cancelOperation: jest.Mock;
|
||||
createOperationId: jest.Mock;
|
||||
getXtreamImportStatus: jest.Mock;
|
||||
setXtreamImportStatus: jest.Mock;
|
||||
supportsDbOperationCancellation: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
let operationCounter = 0;
|
||||
dataSource = {
|
||||
getCategories: jest.fn().mockResolvedValue([]),
|
||||
getCachedCategories: jest.fn().mockResolvedValue([]),
|
||||
getContent: jest.fn().mockResolvedValue([]),
|
||||
getCachedContent: jest.fn().mockResolvedValue([]),
|
||||
hasCategories: jest.fn().mockResolvedValue(true),
|
||||
hasContent: jest.fn().mockResolvedValue(false),
|
||||
restoreUserData: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
databaseService = {
|
||||
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
|
||||
cancelOperation: jest.fn().mockResolvedValue(true),
|
||||
createOperationId: jest
|
||||
.fn()
|
||||
.mockImplementation((prefix?: string) => {
|
||||
if (prefix === 'xtream-import-session') {
|
||||
return 'xtream-import-session';
|
||||
}
|
||||
|
||||
operationCounter += 1;
|
||||
return `${prefix ?? 'db-op'}-${operationCounter}`;
|
||||
}),
|
||||
getXtreamImportStatus: jest.fn().mockResolvedValue('completed'),
|
||||
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
|
||||
supportsDbOperationCancellation: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
checkPortalStatusMock = jest.fn().mockResolvedValue('active');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestContentStore,
|
||||
{
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
useValue: dataSource,
|
||||
},
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: databaseService,
|
||||
},
|
||||
{
|
||||
provide: XtreamApiService,
|
||||
useValue: { cancelSession: jest.fn().mockResolvedValue(true) },
|
||||
},
|
||||
{
|
||||
provide: XtreamPendingRestoreService,
|
||||
useValue: createPendingRestoreServiceMock(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(TestContentStore);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('publishes the loading-cached phase without marking the import as running', async () => {
|
||||
const gate = createDeferred<unknown[]>();
|
||||
dataSource.getCategories.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
_credentials: unknown,
|
||||
_type: string,
|
||||
options?: PhaseOptions
|
||||
) => {
|
||||
options?.onPhaseChange?.('loading-cached');
|
||||
return gate.promise;
|
||||
}
|
||||
);
|
||||
|
||||
const initialization = store.initializeContent();
|
||||
await waitForCondition(() => store.importPhase() === 'loading-cached');
|
||||
|
||||
expect(store.isImporting()).toBe(false);
|
||||
|
||||
gate.resolve([]);
|
||||
await initialization;
|
||||
});
|
||||
|
||||
it('keeps a cancelled warm cached read out of import cache cleanup', async () => {
|
||||
dataSource.getCategories.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
_credentials: unknown,
|
||||
_type: string,
|
||||
options?: PhaseOptions
|
||||
) => {
|
||||
options?.onPhaseChange?.('loading-cached');
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
);
|
||||
dataSource.getContent.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
_credentials: unknown,
|
||||
type: ContentType,
|
||||
_onProgress?: (count: number) => void,
|
||||
_onTotal?: (total: number) => void,
|
||||
options?: PhaseOptions
|
||||
) => {
|
||||
options?.onPhaseChange?.('loading-cached');
|
||||
if (type === 'movie') {
|
||||
return Promise.reject(createAbortError());
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
);
|
||||
|
||||
await store.initializeContent();
|
||||
|
||||
// The warm start never touched the provider, so its cancellation must
|
||||
// not wipe the healthy cached catalog.
|
||||
expect(databaseService.clearXtreamImportCache).not.toHaveBeenCalled();
|
||||
expect(store.contentInitBlockReason()).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('clears only the type that performed remote work when a mixed import is cancelled', async () => {
|
||||
dataSource.getCategories.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
_credentials: unknown,
|
||||
_type: string,
|
||||
options?: PhaseOptions
|
||||
) => {
|
||||
options?.onPhaseChange?.('loading-cached');
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
);
|
||||
dataSource.getContent.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
_credentials: unknown,
|
||||
type: ContentType,
|
||||
_onProgress?: (count: number) => void,
|
||||
_onTotal?: (total: number) => void,
|
||||
options?: PhaseOptions
|
||||
) => {
|
||||
if (type === 'live') {
|
||||
// Live misses the cache, contacts the provider, then the
|
||||
// user cancels mid-download.
|
||||
options?.onPhaseChange?.('loading-live');
|
||||
return Promise.reject(createAbortError());
|
||||
}
|
||||
options?.onPhaseChange?.('loading-cached');
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
);
|
||||
|
||||
await store.initializeContent();
|
||||
|
||||
// Only live held partial remote data; vod/series were cache-only and
|
||||
// must keep their healthy catalogs.
|
||||
expect(databaseService.clearXtreamImportCache).toHaveBeenCalledTimes(1);
|
||||
expect(databaseService.clearXtreamImportCache).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'live'
|
||||
);
|
||||
});
|
||||
|
||||
it('still marks the import as running once a remote phase is reported', async () => {
|
||||
dataSource.getCategories.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
_credentials: unknown,
|
||||
_type: string,
|
||||
options?: PhaseOptions
|
||||
) => {
|
||||
options?.onPhaseChange?.('loading-cached');
|
||||
options?.onPhaseChange?.('loading-categories');
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
);
|
||||
const gate = createDeferred<unknown[]>();
|
||||
dataSource.getContent.mockImplementation(() => gate.promise);
|
||||
|
||||
const initialization = store.initializeContent();
|
||||
await waitForCondition(
|
||||
() => store.importPhase() === 'loading-categories'
|
||||
);
|
||||
|
||||
expect(store.isImporting()).toBe(true);
|
||||
|
||||
gate.resolve([]);
|
||||
await initialization;
|
||||
});
|
||||
});
|
||||
@@ -222,6 +222,12 @@ export function withContent() {
|
||||
const xtreamApiService = inject(XtreamApiService);
|
||||
const importTypes: ContentType[] = ['live', 'vod', 'series'];
|
||||
let activeInitializationPromise: Promise<void> | null = null;
|
||||
// Types that actually contacted the provider (or saved remote
|
||||
// data) in the current initialization. Cancellation cleanup must
|
||||
// only clear these: a type served entirely from the local cache
|
||||
// has nothing partial to clean up, and clearing it would throw
|
||||
// away a healthy catalog and force a full redownload.
|
||||
const sessionRemoteWorkTypes = new Set<ContentType>();
|
||||
let cachedHydrationGeneration = 0;
|
||||
const activeCachedHydrationPromises = new Map<
|
||||
string,
|
||||
@@ -672,6 +678,28 @@ export function withContent() {
|
||||
return request;
|
||||
};
|
||||
|
||||
const publishImportPhase = (phase: string): void => {
|
||||
patchState(store, (state) => ({
|
||||
// 'loading-cached' is a read-only presentation phase. It
|
||||
// must not mark a real import as started: the error path
|
||||
// gates cache cleanup on isImporting, so flagging a warm
|
||||
// DB read would let a cancellation wipe the healthy
|
||||
// cached catalog and force a full provider redownload.
|
||||
isImporting:
|
||||
state.isImporting || phase !== 'loading-cached',
|
||||
importPhase: phase,
|
||||
}));
|
||||
};
|
||||
|
||||
const publishTypedImportPhase =
|
||||
(type: ContentType) =>
|
||||
(phase: string): void => {
|
||||
if (phase !== 'loading-cached') {
|
||||
sessionRemoteWorkTypes.add(type);
|
||||
}
|
||||
publishImportPhase(phase);
|
||||
};
|
||||
|
||||
const trackImportEvent = (event: DbOperationEvent): void => {
|
||||
const operationId = event.operationId;
|
||||
|
||||
@@ -717,6 +745,12 @@ export function withContent() {
|
||||
event.operation === 'save-content' &&
|
||||
store.activeImportContentType()
|
||||
) {
|
||||
// A save event proves this type is writing remote data,
|
||||
// independent of the loading phase that preceded it.
|
||||
const activeType = store.activeImportContentType();
|
||||
if (activeType) {
|
||||
sessionRemoteWorkTypes.add(activeType);
|
||||
}
|
||||
patchState(store, (state) => ({
|
||||
activeImportCurrentCount:
|
||||
event.current ?? state.activeImportCurrentCount,
|
||||
@@ -769,6 +803,14 @@ export function withContent() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only types that performed remote/save work this session
|
||||
// can hold partial data. A type still pending because it
|
||||
// was being read from the local cache keeps its healthy
|
||||
// catalog instead of being cleared into a redownload.
|
||||
if (!sessionRemoteWorkTypes.has(type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await setImportStatus(playlistId, type, status);
|
||||
await clearImportCache(playlistId, type);
|
||||
}
|
||||
@@ -830,6 +872,7 @@ export function withContent() {
|
||||
'xtream-import-session'
|
||||
);
|
||||
|
||||
sessionRemoteWorkTypes.clear();
|
||||
patchState(store, {
|
||||
isImporting: false,
|
||||
isCancellingImport: false,
|
||||
@@ -1059,11 +1102,7 @@ export function withContent() {
|
||||
'live',
|
||||
{
|
||||
sessionId: options?.sessionId,
|
||||
onPhaseChange: (phase) =>
|
||||
patchState(store, {
|
||||
isImporting: true,
|
||||
importPhase: phase,
|
||||
}),
|
||||
onPhaseChange: publishTypedImportPhase('live'),
|
||||
}
|
||||
),
|
||||
dataSource.getCategories(
|
||||
@@ -1072,11 +1111,7 @@ export function withContent() {
|
||||
'vod',
|
||||
{
|
||||
sessionId: options?.sessionId,
|
||||
onPhaseChange: (phase) =>
|
||||
patchState(store, {
|
||||
isImporting: true,
|
||||
importPhase: phase,
|
||||
}),
|
||||
onPhaseChange: publishTypedImportPhase('vod'),
|
||||
}
|
||||
),
|
||||
dataSource.getCategories(
|
||||
@@ -1085,11 +1120,7 @@ export function withContent() {
|
||||
'series',
|
||||
{
|
||||
sessionId: options?.sessionId,
|
||||
onPhaseChange: (phase) =>
|
||||
patchState(store, {
|
||||
isImporting: true,
|
||||
importPhase: phase,
|
||||
}),
|
||||
onPhaseChange: publishTypedImportPhase('series'),
|
||||
}
|
||||
),
|
||||
]);
|
||||
@@ -1172,11 +1203,7 @@ export function withContent() {
|
||||
operationId: liveOperationId,
|
||||
sessionId: options?.sessionId,
|
||||
onEvent: trackImportEvent,
|
||||
onPhaseChange: (phase) =>
|
||||
patchState(store, {
|
||||
isImporting: true,
|
||||
importPhase: phase,
|
||||
}),
|
||||
onPhaseChange: publishTypedImportPhase('live'),
|
||||
}
|
||||
)) as XtreamLiveStream[];
|
||||
throwIfImportCancelled(options?.importSessionId);
|
||||
@@ -1213,11 +1240,7 @@ export function withContent() {
|
||||
operationId: vodOperationId,
|
||||
sessionId: options?.sessionId,
|
||||
onEvent: trackImportEvent,
|
||||
onPhaseChange: (phase) =>
|
||||
patchState(store, {
|
||||
isImporting: true,
|
||||
importPhase: phase,
|
||||
}),
|
||||
onPhaseChange: publishTypedImportPhase('vod'),
|
||||
}
|
||||
)) as XtreamVodStream[];
|
||||
throwIfImportCancelled(options?.importSessionId);
|
||||
@@ -1255,11 +1278,7 @@ export function withContent() {
|
||||
operationId: seriesOperationId,
|
||||
sessionId: options?.sessionId,
|
||||
onEvent: trackImportEvent,
|
||||
onPhaseChange: (phase) =>
|
||||
patchState(store, {
|
||||
isImporting: true,
|
||||
importPhase: phase,
|
||||
}),
|
||||
onPhaseChange: publishTypedImportPhase('series'),
|
||||
}
|
||||
)) as XtreamSerieItem[];
|
||||
throwIfImportCancelled(options?.importSessionId);
|
||||
|
||||
@@ -1,477 +1,10 @@
|
||||
@use '../../../../../../ui/styles/account-dialog';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
// ─── Hero header ────────────────────────────────────────────────────────────
|
||||
.account-dialog__hero {
|
||||
margin: 0;
|
||||
padding: 20px 20px 18px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
background:
|
||||
var(--app-accent-gradient),
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
|
||||
border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.account-dialog__hero-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-dialog__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-dialog__eyebrow-mark {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-provider-xtream, var(--mat-sys-primary));
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 14%, transparent);
|
||||
}
|
||||
|
||||
.account-dialog__eyebrow-divider {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.account-dialog__headline-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.account-dialog__title {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.account-dialog__subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
// ─── Status pills ───────────────────────────────────────────────────────────
|
||||
.account-dialog__status-cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.account-dialog__status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--app-separator);
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-dialog__status-pill--active {
|
||||
border-color: color-mix(in srgb, #34d399 30%, transparent);
|
||||
background: color-mix(in srgb, #34d399 12%, transparent);
|
||||
color: #2db36f;
|
||||
}
|
||||
|
||||
.account-dialog__status-pill--trial {
|
||||
border-color: color-mix(in srgb, #fbbf24 28%, transparent);
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
color: #d18b09;
|
||||
}
|
||||
|
||||
// ─── Messages ───────────────────────────────────────────────────────────────
|
||||
.account-dialog__message,
|
||||
.account-dialog__loading-copy {
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.account-dialog__message {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--app-separator);
|
||||
background: var(--mat-sys-surface-container);
|
||||
line-height: 1.5;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
// ─── Hero stat cards ────────────────────────────────────────────────────────
|
||||
.account-dialog__hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-stat {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--app-widget-border, transparent);
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface-container));
|
||||
box-shadow: var(--app-widget-shadow, none);
|
||||
// Subtle inset highlight matching widget-shell pattern
|
||||
outline: 1px solid rgba(255, 255, 255, 0.04);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.account-stat__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 12%, transparent);
|
||||
color: var(--app-provider-xtream, var(--mat-sys-primary));
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-stat__label {
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-stat__value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.account-stat__meter {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-separator);
|
||||
}
|
||||
|
||||
.account-stat__meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--app-provider-xtream, var(--mat-sys-primary));
|
||||
}
|
||||
|
||||
// ─── Content area ───────────────────────────────────────────────────────────
|
||||
.account-dialog__content {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.account-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
// ─── Detail panels ──────────────────────────────────────────────────────────
|
||||
.account-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--app-widget-border, transparent);
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
|
||||
box-shadow: var(--app-widget-shadow, none);
|
||||
outline: 1px solid rgba(255, 255, 255, 0.04);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.account-panel__header {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.account-panel__header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.account-panel__eyebrow {
|
||||
margin: 0;
|
||||
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// ─── Detail rows ────────────────────────────────────────────────────────────
|
||||
.account-detail-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.account-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 140px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.account-detail-row:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.account-detail-row dt {
|
||||
margin: 0;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-detail-row dd {
|
||||
margin: 0;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.account-detail-row__value--mono {
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.account-detail-row__value--accent {
|
||||
color: var(--app-provider-xtream, var(--mat-sys-primary));
|
||||
}
|
||||
|
||||
.account-detail-row__value--positive {
|
||||
color: #2db36f;
|
||||
}
|
||||
|
||||
.account-detail-row__value--warning {
|
||||
color: #d18b09;
|
||||
}
|
||||
|
||||
// ─── Chip sections ──────────────────────────────────────────────────────────
|
||||
.account-chip-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-chip-section__label {
|
||||
margin: 0;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 16%, transparent);
|
||||
background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 8%, transparent);
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-chip--mono {
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.account-chip--muted {
|
||||
border-color: var(--app-separator);
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
}
|
||||
|
||||
// ─── Error state ────────────────────────────────────────────────────────────
|
||||
.account-dialog__error-state {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
padding: 36px 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed var(--app-separator);
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.account-dialog__error-state h3,
|
||||
.account-dialog__error-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.account-dialog__error-state p {
|
||||
max-width: 52ch;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
line-height: 1.5;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.account-dialog__error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
// ─── Skeleton loading ───────────────────────────────────────────────────────
|
||||
.account-panel--loading {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--app-separator);
|
||||
}
|
||||
|
||||
.account-skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--app-heading-color, var(--mat-sys-on-surface)) 10%, transparent),
|
||||
transparent
|
||||
--account-dialog-accent: var(
|
||||
--app-provider-xtream,
|
||||
var(--mat-sys-primary)
|
||||
);
|
||||
animation: account-dialog-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.account-skeleton--stat {
|
||||
min-height: 110px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.account-skeleton--heading {
|
||||
width: 54%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.account-skeleton--row {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.account-skeleton--row.short {
|
||||
width: 62%;
|
||||
}
|
||||
|
||||
// ─── Footer actions ─────────────────────────────────────────────────────────
|
||||
.account-dialog__actions {
|
||||
padding: 0 20px 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.account-dialog__close {
|
||||
min-width: 100px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@keyframes account-dialog-shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Responsive breakpoints ─────────────────────────────────────────────────
|
||||
@media (max-width: 980px) {
|
||||
.account-dialog__hero-stats,
|
||||
.account-dialog__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.account-dialog__hero,
|
||||
.account-dialog__content,
|
||||
.account-dialog__actions {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.account-dialog__headline-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.account-dialog__status-cluster {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.account-dialog__hero-stats,
|
||||
.account-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-detail-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -43,6 +43,11 @@
|
||||
|
||||
.category-content-header {
|
||||
@include panel.standard-panel-header($sticky: true);
|
||||
|
||||
// The paginator does not shrink, so on any window narrow enough that it
|
||||
// and the category meta cannot share a line it took the whole row and the
|
||||
// meta collapsed underneath it. Let it drop to a line of its own instead.
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
@@ -59,7 +64,9 @@
|
||||
|
||||
mat-paginator {
|
||||
background: transparent !important;
|
||||
flex-shrink: 0;
|
||||
// Its own container already wraps internally, so letting it shrink turns
|
||||
// a clipped next-page arrow into a second line of the paginator itself.
|
||||
min-width: 0;
|
||||
--mat-paginator-container-size: 40px;
|
||||
--mat-paginator-enabled-icon-color: var(
|
||||
--app-body-color,
|
||||
@@ -116,3 +123,28 @@ app-grid-list.live-all-items-grid {
|
||||
line-height: 1.45;
|
||||
opacity: 0.74;
|
||||
}
|
||||
|
||||
// The header is one flex row of "N channels" plus the paginator. The
|
||||
// paginator's intrinsic width alone exceeds a phone, which crushed the count
|
||||
// to zero width and left the two drawn on top of each other.
|
||||
@media (max-width: 640px) {
|
||||
.category-content-header {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
// The paginator already states "1 – 25 of 320" right next to it, and the
|
||||
// room it costs here is taken straight out of the category title.
|
||||
.category-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
min-width: 0;
|
||||
|
||||
// Dropped for the same reason as the catalog grid: it is the widest
|
||||
// part of the control and the least useful one on a phone.
|
||||
::ng-deep .mat-mdc-paginator-page-size {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -510,7 +510,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.key.toLowerCase() === 'b' &&
|
||||
!isTypingInInput(event)
|
||||
!isTypingInInput(event) &&
|
||||
// Behind the workspace's phone context drawer the route content
|
||||
// is inert; this document-level listener still fires, so it
|
||||
// opts out itself instead of toggling an obscured sidebar.
|
||||
!this.hostElement.nativeElement.closest('[inert]')
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.toggleSidebar();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DownloadsService } from '@iptvnator/services';
|
||||
import { resolveXtreamVodPlaybackSource } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
getXtreamVodInfo,
|
||||
XTREAM_CLIENT_USER_AGENT,
|
||||
XtreamVodDetails,
|
||||
type XtreamVodInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -179,7 +180,8 @@ export class VodDetailsDownloadsService {
|
||||
: textList(info?.director)?.map((name) => ({ name })),
|
||||
}),
|
||||
headers: {
|
||||
userAgent: playlist.userAgent,
|
||||
userAgent:
|
||||
playlist.userAgent?.trim() || XTREAM_CLIENT_USER_AGENT,
|
||||
referer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
},
|
||||
|
||||
@@ -462,6 +462,9 @@ describe('VodDetailsRouteComponent — playback actions', () => {
|
||||
// came from — so the download would fetch the wrong movie.
|
||||
expect(stubs.startDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
userAgent: 'VLC/3.0.18 LibVLC/3.0.18',
|
||||
}),
|
||||
playlistId: 'playlist-1',
|
||||
xtreamId: 650020,
|
||||
metadataSnapshot: expect.objectContaining({
|
||||
|
||||
@@ -399,6 +399,11 @@ describe('VodDetailsRouteComponent fallback actions', () => {
|
||||
);
|
||||
expect(startDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
origin: 'https://origin.example',
|
||||
referer: 'https://referrer.example',
|
||||
userAgent: 'IPTVnator',
|
||||
},
|
||||
title: 'Catalog movie',
|
||||
posterUrl: 'https://example.com/catalog-poster.jpg',
|
||||
url: 'http://example.com/movie/650020.mp4',
|
||||
|
||||
@@ -188,6 +188,55 @@ describe('PortalStatusService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns expiry details from the same round-trip and serves repeats from the cache', async () => {
|
||||
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3 * 86_400;
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
user_info: {
|
||||
auth: 1,
|
||||
status: 'Active',
|
||||
exp_date: String(expiresAtSeconds),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const details = await service.checkPortalStatusDetails(
|
||||
'http://example.com',
|
||||
'user',
|
||||
'pass'
|
||||
);
|
||||
|
||||
expect(details).toEqual({ status: 'active', expiresAtSeconds });
|
||||
|
||||
const cached = await service.checkPortalStatusDetails(
|
||||
'http://example.com',
|
||||
'user',
|
||||
'pass'
|
||||
);
|
||||
expect(cached).toEqual(details);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a null expiry for unlimited accounts', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
user_info: {
|
||||
auth: 1,
|
||||
status: 'Active',
|
||||
exp_date: '0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.checkPortalStatusDetails(
|
||||
'http://example.com',
|
||||
'user',
|
||||
'pass'
|
||||
)
|
||||
).resolves.toEqual({ status: 'active', expiresAtSeconds: null });
|
||||
});
|
||||
|
||||
it('treats lowercase active status and exp_date 0 as active', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
normalizeXtreamServerUrl,
|
||||
resolveXtreamPortalExpiration,
|
||||
resolveXtreamPortalStatus,
|
||||
XtreamPortalStatusResponseLike,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -13,12 +14,24 @@ export type PortalStatus =
|
||||
| 'unavailable'
|
||||
| 'checking';
|
||||
|
||||
/**
|
||||
* Status plus the parsed account expiration from the same round-trip the
|
||||
* status check already makes — consumers that care about "expires soon"
|
||||
* (dashboard source cards) share the cache instead of issuing their own
|
||||
* `get_account_info` calls.
|
||||
*/
|
||||
export interface PortalStatusDetails {
|
||||
status: PortalStatus;
|
||||
/** Unix seconds; null when the portal doesn't report an expiration. */
|
||||
expiresAtSeconds: number | null;
|
||||
}
|
||||
|
||||
interface XtreamPortalStatusResponse {
|
||||
payload?: XtreamPortalStatusResponseLike;
|
||||
}
|
||||
|
||||
interface PortalStatusCacheEntry {
|
||||
status: PortalStatus;
|
||||
details: PortalStatusDetails;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -57,7 +70,7 @@ export class PortalStatusService {
|
||||
* playlist-item + switcher menu open) share a single network round-trip
|
||||
* instead of racing each other.
|
||||
*/
|
||||
private readonly inFlight = new Map<string, Promise<PortalStatus>>();
|
||||
private readonly inFlight = new Map<string, Promise<PortalStatusDetails>>();
|
||||
|
||||
/**
|
||||
* Checks the status of an Xtream Code portal
|
||||
@@ -75,13 +88,32 @@ export class PortalStatusService {
|
||||
password: string,
|
||||
options?: CheckPortalStatusOptions
|
||||
): Promise<PortalStatus> {
|
||||
const details = await this.checkPortalStatusDetails(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
options
|
||||
);
|
||||
return details.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same check as {@link checkPortalStatus} (shared cache, shared in-flight
|
||||
* dedup) but returns the account expiration alongside the status.
|
||||
*/
|
||||
async checkPortalStatusDetails(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options?: CheckPortalStatusOptions
|
||||
): Promise<PortalStatusDetails> {
|
||||
const connection = this.normalizeConnection(
|
||||
serverUrl,
|
||||
username,
|
||||
password
|
||||
);
|
||||
if (!connection) {
|
||||
return 'unavailable';
|
||||
return { status: 'unavailable', expiresAtSeconds: null };
|
||||
}
|
||||
|
||||
const cacheKey = this.buildCacheKey(
|
||||
@@ -96,7 +128,7 @@ export class PortalStatusService {
|
||||
cached &&
|
||||
Date.now() - cached.timestamp < PORTAL_STATUS_CACHE_TTL_MS
|
||||
) {
|
||||
return cached.status;
|
||||
return cached.details;
|
||||
}
|
||||
|
||||
const pending = this.inFlight.get(cacheKey);
|
||||
@@ -110,12 +142,12 @@ export class PortalStatusService {
|
||||
connection.username,
|
||||
connection.password
|
||||
)
|
||||
.then((status) => {
|
||||
.then((details) => {
|
||||
this.cache.set(cacheKey, {
|
||||
status,
|
||||
details,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return status;
|
||||
return details;
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlight.delete(cacheKey);
|
||||
@@ -159,7 +191,7 @@ export class PortalStatusService {
|
||||
if (Date.now() - cached.timestamp >= PORTAL_STATUS_CACHE_TTL_MS) {
|
||||
return null;
|
||||
}
|
||||
return cached.status;
|
||||
return cached.details.status;
|
||||
}
|
||||
|
||||
/** Clear the entire cache. Useful for log-out or debug flows. */
|
||||
@@ -205,7 +237,7 @@ export class PortalStatusService {
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<PortalStatus> {
|
||||
): Promise<PortalStatusDetails> {
|
||||
for (const action of XTREAM_STATUS_ACTIONS) {
|
||||
try {
|
||||
const response =
|
||||
@@ -223,14 +255,19 @@ export class PortalStatusService {
|
||||
);
|
||||
const status = resolveXtreamPortalStatus(response?.payload);
|
||||
if (status !== 'unavailable') {
|
||||
return status;
|
||||
return {
|
||||
status,
|
||||
expiresAtSeconds: resolveXtreamPortalExpiration(
|
||||
response?.payload
|
||||
),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Try the next Xtream account-info action variant.
|
||||
}
|
||||
}
|
||||
|
||||
return 'unavailable';
|
||||
return { status: 'unavailable', expiresAtSeconds: null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
@@ -37,12 +38,14 @@ export * from './lib/portal-playback.interface';
|
||||
export * from './lib/random-id.util';
|
||||
export * from './lib/security-policy-error.utils';
|
||||
export * from './lib/settings.interface';
|
||||
export * from './lib/stalker-cmd-encoding.util';
|
||||
export * from './lib/stalker-portal-actions.enum';
|
||||
export * from './lib/store-keys.enum';
|
||||
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';
|
||||
@@ -65,7 +68,10 @@ export * from './lib/xtream-vod-stream.interface';
|
||||
// Stalker interfaces
|
||||
export * from './lib/stalker-item.normalizer';
|
||||
export * from './lib/stalker-identity.utils';
|
||||
export * from './lib/stalker-request-identity.util';
|
||||
export * from './lib/stalker-request-url.util';
|
||||
export * from './lib/stalker-portal-item.interface';
|
||||
export * from './lib/stalker-stream-profile.util';
|
||||
export * from './lib/stalker-serial-details.interface';
|
||||
export * from './lib/stalker-vod-details.interface';
|
||||
|
||||
|
||||
@@ -193,6 +193,17 @@ export interface ElectronBridgeWindowState {
|
||||
isFullScreen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal credentials for an auth-gated stream, passed alongside the scoped
|
||||
* header override. The main process attaches them only to requests going to
|
||||
* the exact origin of the override's `scopeUrl`, keeps them in memory only,
|
||||
* and drops them when the scoped override is cleared or replaced.
|
||||
*/
|
||||
export interface ElectronBridgeStreamCredentials {
|
||||
authorization?: string | null;
|
||||
cookie?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A playlist file the operating system asked the app to open — a command line
|
||||
* argument, a file association double-click, or macOS' `open-file` event. The
|
||||
@@ -632,7 +643,8 @@ export interface ElectronBridgeApi {
|
||||
setUserAgent: (
|
||||
userAgent?: string | null,
|
||||
referer?: string | null,
|
||||
scopeUrl?: string | null
|
||||
scopeUrl?: string | null,
|
||||
credentials?: ElectronBridgeStreamCredentials | null
|
||||
) => Promise<boolean>;
|
||||
openInMpv: (
|
||||
url: string,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { encodeStalkerCmdValue } from './stalker-cmd-encoding.util';
|
||||
|
||||
/**
|
||||
* One application-x-www-form-urlencoded decode, the way PHP's `$_GET` (and
|
||||
* Express' query parser) sees the value: `+` is a space, `%XX` decodes once.
|
||||
*/
|
||||
function portalVisibleValue(encoded: string): string {
|
||||
return decodeURIComponent(encoded.replace(/\+/g, '%20'));
|
||||
}
|
||||
|
||||
describe('encodeStalkerCmdValue', () => {
|
||||
it('keeps a plain solution-prefixed cmd readable (space only)', () => {
|
||||
expect(encodeStalkerCmdValue('ffrt3 http://host/ch/123')).toBe(
|
||||
'ffrt3%20http://host/ch/123'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ?, = and : raw in a tokened cmd', () => {
|
||||
expect(
|
||||
encodeStalkerCmdValue('auto http://host/ch/123?token=abc')
|
||||
).toBe('auto%20http://host/ch/123?token=abc');
|
||||
});
|
||||
|
||||
it('leaves a bare media path untouched', () => {
|
||||
expect(encodeStalkerCmdValue('/media/12345.mpg')).toBe(
|
||||
'/media/12345.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('never double-encodes pre-encoded sequences', () => {
|
||||
expect(
|
||||
encodeStalkerCmdValue('auto http://host/s/a%3Ab%20c.m3u8')
|
||||
).toBe('auto%20http://host/s/a%3Ab%20c.m3u8');
|
||||
});
|
||||
|
||||
it('passes a bare, malformed % through untouched', () => {
|
||||
expect(encodeStalkerCmdValue('/media/100%.mpg')).toBe(
|
||||
'/media/100%.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes the query-structure characters &, # and ;', () => {
|
||||
expect(encodeStalkerCmdValue('a&b#c;d')).toBe('a%26b%23c%3Bd');
|
||||
});
|
||||
|
||||
it('keeps + raw so the portal sees a space, like it does for a real STB', () => {
|
||||
const encoded = encodeStalkerCmdValue('auto http://host/s/a+b.ts');
|
||||
expect(encoded).toBe('auto%20http://host/s/a+b.ts');
|
||||
expect(portalVisibleValue(encoded)).toBe('auto http://host/s/a b.ts');
|
||||
});
|
||||
|
||||
it('keeps IPv6 literals byte-identical', () => {
|
||||
expect(encodeStalkerCmdValue('ffmpeg http://[::1]:8080/ch/1')).toBe(
|
||||
'ffmpeg%20http://[::1]:8080/ch/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes quotes, backslash and control characters', () => {
|
||||
expect(encodeStalkerCmdValue(`a"b'c\\d\ne`)).toBe(
|
||||
'a%22b%27c%5Cd%0Ae'
|
||||
);
|
||||
});
|
||||
|
||||
it('percent-encodes non-ASCII as UTF-8', () => {
|
||||
expect(encodeStalkerCmdValue('auto http://host/канал/1')).toBe(
|
||||
'auto%20http://host/%D0%BA%D0%B0%D0%BD%D0%B0%D0%BB/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('survives WHATWG URL re-parsing byte-identically', () => {
|
||||
const corpus = [
|
||||
'ffrt3 http://host/ch/123',
|
||||
'auto http://host/ch/123?token=abc',
|
||||
'/media/12345.mpg',
|
||||
'auto http://host/s/a%3Ab%20c.m3u8',
|
||||
"a&b#c;d'e\"f",
|
||||
'ffmpeg http://[::1]:8080/ch/1',
|
||||
'auto http://host/канал/1',
|
||||
];
|
||||
for (const cmd of corpus) {
|
||||
const query = `cmd=${encodeStalkerCmdValue(cmd)}`;
|
||||
expect(new URL(`http://portal/load.php?${query}`).search).toBe(
|
||||
`?${query}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('is value-preserving after one server-side decode for every escaped character', () => {
|
||||
const original = `ffrt http://h/s?a=1&b=2#f;g "q" 'x' я`;
|
||||
expect(portalVisibleValue(encodeStalkerCmdValue(original))).toBe(
|
||||
original
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Wire encoding for the Stalker `cmd` query parameter.
|
||||
*
|
||||
* A real MAG/STB sends `cmd` unencoded: the portal's own client JS
|
||||
* concatenates raw `key=value` pairs, WebKit's URL layer escapes only the
|
||||
* characters a URL cannot carry (space, quotes, non-ASCII), and PHP's `$_GET`
|
||||
* then applies exactly one form-urldecode. The portal therefore sees the
|
||||
* stored cmd decoded **once** — pre-encoded sequences such as `%3A` arrive as
|
||||
* `:`, and a literal `+` arrives as a space.
|
||||
*
|
||||
* `encodeURIComponent` (the previous behavior) broke that contract for any
|
||||
* cmd already containing `%`: `%3A` went out as `%253A` and reached the
|
||||
* portal still encoded, so strict panels and the stock
|
||||
* `preg_match`-based `create_link` handlers saw a different string than a
|
||||
* real STB would send.
|
||||
*
|
||||
* This encoder reproduces the reference wire bytes instead:
|
||||
*
|
||||
* - `%` passes through untouched (never double-encoded);
|
||||
* - characters the WHATWG URL serializer keeps raw in a query stay raw
|
||||
* (`/ : ? = + , @ $ [ ] ! * ( ) ~ - _ .`), so the bytes we emit survive
|
||||
* `new URL(...)` unchanged;
|
||||
* - everything else is percent-encoded. That covers the characters that
|
||||
* would restructure our request (`&`, `#`, and `;` for PHP setups with a
|
||||
* `;` argument separator) — a malicious portal cannot smuggle extra query
|
||||
* parameters through cmd — plus characters a URL cannot carry raw (space,
|
||||
* quotes, control characters, non-ASCII). All of them decode back to the
|
||||
* original byte on the server, so the portal-visible value is unaffected.
|
||||
*/
|
||||
|
||||
const SAFE_CMD_CHAR = /^[A-Za-z0-9\-_.~!*()/:?@$,+=[\]%]$/;
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Percent-encode every UTF-8 byte of a character. Deliberately not
|
||||
* `encodeURIComponent`, whose unreserved set (e.g. `'`) overlaps characters
|
||||
* this encoder must escape because `new URL(...)` would re-encode them and
|
||||
* change the wire bytes behind our back.
|
||||
*/
|
||||
function percentEncodeChar(char: string): string {
|
||||
let encoded = '';
|
||||
for (const byte of utf8Encoder.encode(char)) {
|
||||
encoded += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function encodeStalkerCmdValue(value: string): string {
|
||||
let encoded = '';
|
||||
for (const char of value) {
|
||||
encoded += SAFE_CMD_CHAR.test(char) ? char : percentEncodeChar(char);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { LEGACY_DEFAULT_STALKER_SERIAL } from './stalker-identity.utils';
|
||||
import { buildStalkerIdentityRequestContext } from './stalker-request-identity.util';
|
||||
|
||||
describe('buildStalkerIdentityRequestContext', () => {
|
||||
const macAddress = '00:1A:79:AA:BB:CC';
|
||||
|
||||
it('does not add SN header or force get_profile SN fields when serial is absent', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
params: {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
sn: 'STALE-SN',
|
||||
metrics: JSON.stringify({
|
||||
mac: macAddress,
|
||||
sn: 'STALE-SN',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.effectiveSerialNumber).toBeUndefined();
|
||||
expect(context.headers).not.toHaveProperty('SN');
|
||||
expect(context.cookieString).not.toContain('__cfduid=');
|
||||
expect(context.requestParams).not.toHaveProperty('sn');
|
||||
expect(
|
||||
JSON.parse(String(context.requestParams['metrics']))
|
||||
).not.toHaveProperty('sn');
|
||||
});
|
||||
|
||||
it('preserves a provided serial exactly in headers, params, and metrics', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
serialNumber: 'CustomSn123',
|
||||
params: {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
metrics: JSON.stringify({
|
||||
mac: macAddress,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.effectiveSerialNumber).toBe('CustomSn123');
|
||||
expect(context.headers['SN']).toBe('CustomSn123');
|
||||
expect(context.cookieString).toContain('__cfduid=');
|
||||
expect(
|
||||
context.cookieString.match(/__cfduid=([^;]+)/)?.[1]
|
||||
).toHaveLength(32);
|
||||
expect(context.requestParams['sn']).toBe('CustomSn123');
|
||||
expect(JSON.parse(String(context.requestParams['metrics']))).toEqual(
|
||||
expect.objectContaining({
|
||||
sn: 'CustomSn123',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats the legacy default serial as absent', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
serialNumber: LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
params: {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
sn: LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
metrics: JSON.stringify({
|
||||
mac: macAddress,
|
||||
sn: LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.effectiveSerialNumber).toBeUndefined();
|
||||
expect(context.headers).not.toHaveProperty('SN');
|
||||
expect(context.cookieString).not.toContain('__cfduid=');
|
||||
expect(context.requestParams).not.toHaveProperty('sn');
|
||||
expect(
|
||||
JSON.parse(String(context.requestParams['metrics']))
|
||||
).not.toHaveProperty('sn');
|
||||
});
|
||||
|
||||
it('removes stale SN params from non-profile requests', () => {
|
||||
const context = buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
params: {
|
||||
type: 'itv',
|
||||
action: 'get_ordered_list',
|
||||
sn: 'STALE-SN',
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.requestParams).not.toHaveProperty('sn');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
buildStalkerSerialCfduid,
|
||||
normalizeStalkerSerialNumber,
|
||||
} from './stalker-identity.utils';
|
||||
|
||||
/**
|
||||
* User-Agent of a MAG250 set-top box (matches the stalker-to-m3u reference
|
||||
* implementation). Sent as both `User-Agent` and `X-User-Agent` on every
|
||||
* portal API request — some panels reject requests without it.
|
||||
*/
|
||||
export const STALKER_MAG_USER_AGENT =
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
|
||||
|
||||
export interface StalkerIdentityRequestInput {
|
||||
macAddress: string;
|
||||
params: Record<string, string | number>;
|
||||
token?: string;
|
||||
serialNumber?: string;
|
||||
}
|
||||
|
||||
export interface StalkerIdentityRequestContext {
|
||||
requestParams: Record<string, string | number>;
|
||||
headers: Record<string, string>;
|
||||
cookieString: string;
|
||||
effectiveSerialNumber?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the portal-facing identity of a Stalker request: the cookie a real
|
||||
* STB sends (`mac` + `stb_lang` + `timezone`, plus a serial-derived
|
||||
* `__cfduid`), the MAG User-Agent pair, the `SN`/`Authorization` headers, and
|
||||
* the serial-number parameter rules (`sn` travels only on `get_profile`).
|
||||
*
|
||||
* This is the single source of truth for BOTH transports — the Electron main
|
||||
* process and the self-hosted PWA's web-backend `/stalker` proxy — so the two
|
||||
* cannot drift apart again. The stock server reads the timezone cookie with
|
||||
* `new DateTimeZone()`, so it must always be a valid PHP timezone.
|
||||
*/
|
||||
export function buildStalkerIdentityRequestContext({
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
serialNumber,
|
||||
}: StalkerIdentityRequestInput): StalkerIdentityRequestContext {
|
||||
const effectiveSerialNumber = normalizeStalkerSerialNumber(serialNumber);
|
||||
const requestParams = buildRequestParams(params, effectiveSerialNumber);
|
||||
const cookieString = buildCookieString(macAddress, effectiveSerialNumber);
|
||||
const headers: Record<string, string> = {
|
||||
Cookie: cookieString,
|
||||
'User-Agent': STALKER_MAG_USER_AGENT,
|
||||
'X-User-Agent': STALKER_MAG_USER_AGENT,
|
||||
Accept: '*/*',
|
||||
Connection: 'keep-alive',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
};
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
headers['SN'] = effectiveSerialNumber;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return {
|
||||
requestParams,
|
||||
headers,
|
||||
cookieString,
|
||||
...(effectiveSerialNumber ? { effectiveSerialNumber } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequestParams(
|
||||
params: Record<string, string | number>,
|
||||
effectiveSerialNumber: string | undefined
|
||||
): Record<string, string | number> {
|
||||
const requestParams = { ...params };
|
||||
const isProfileRequest =
|
||||
requestParams['type'] === 'stb' &&
|
||||
requestParams['action'] === 'get_profile';
|
||||
|
||||
if (!isProfileRequest) {
|
||||
delete requestParams['sn'];
|
||||
return requestParams;
|
||||
}
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
requestParams['sn'] = effectiveSerialNumber;
|
||||
} else {
|
||||
delete requestParams['sn'];
|
||||
}
|
||||
|
||||
if (typeof requestParams['metrics'] === 'string') {
|
||||
try {
|
||||
const parsedMetrics = JSON.parse(requestParams['metrics']);
|
||||
const nextMetrics = { ...(parsedMetrics ?? {}) };
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
nextMetrics.sn = effectiveSerialNumber;
|
||||
} else {
|
||||
delete nextMetrics.sn;
|
||||
}
|
||||
|
||||
requestParams['metrics'] = JSON.stringify(nextMetrics);
|
||||
} catch {
|
||||
// Keep original metrics payload when malformed.
|
||||
}
|
||||
}
|
||||
|
||||
return requestParams;
|
||||
}
|
||||
|
||||
function buildCookieString(
|
||||
macAddress: string,
|
||||
effectiveSerialNumber: string | undefined
|
||||
): string {
|
||||
const cookieParts = [
|
||||
`mac=${macAddress}`,
|
||||
'stb_lang=en_US@rg=dezzzz',
|
||||
'timezone=Europe/Berlin',
|
||||
];
|
||||
|
||||
if (effectiveSerialNumber) {
|
||||
cookieParts.push(
|
||||
`__cfduid=${buildStalkerSerialCfduid(effectiveSerialNumber)}`
|
||||
);
|
||||
}
|
||||
|
||||
return cookieParts.join('; ');
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { buildStalkerRequestUrl } from './stalker-request-url.util';
|
||||
|
||||
const PORTAL = 'http://portal.example/stalker_portal/server/load.php';
|
||||
|
||||
/** Decode a query value the way PHP's `$_GET` would (one form-urldecode). */
|
||||
function portalVisibleValue(encoded: string): string {
|
||||
return decodeURIComponent(encoded.replace(/\+/g, '%20'));
|
||||
}
|
||||
|
||||
function cmdWireValue(fullUrl: string): string {
|
||||
const match = /[?&]cmd=([^&]*)/.exec(fullUrl);
|
||||
if (!match) {
|
||||
throw new Error(`no cmd param in ${fullUrl}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
describe('buildStalkerRequestUrl', () => {
|
||||
it('builds the reference wire format for a typical create_link request', () => {
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
type: 'itv',
|
||||
action: 'create_link',
|
||||
cmd: 'ffrt3 http://host/ch/123',
|
||||
});
|
||||
|
||||
expect(fullUrl).toBe(
|
||||
'http://portal.example/stalker_portal/server/load.php' +
|
||||
'?type=itv&action=create_link' +
|
||||
'&cmd=ffrt3%20http://host/ch/123&JsHttpRequest=1-xml'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ffrt3 http://host/ch/123', 'ffrt3%20http://host/ch/123'],
|
||||
[
|
||||
'auto http://host/ch/123?token=abc',
|
||||
'auto%20http://host/ch/123?token=abc',
|
||||
],
|
||||
['/media/12345.mpg', '/media/12345.mpg'],
|
||||
[
|
||||
'auto http://host/s/a%3Ab%20c.m3u8',
|
||||
'auto%20http://host/s/a%3Ab%20c.m3u8',
|
||||
],
|
||||
])('sends cmd %s as %s', (cmd, expectedWireValue) => {
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'create_link',
|
||||
cmd,
|
||||
});
|
||||
|
||||
expect(cmdWireValue(fullUrl)).toBe(expectedWireValue);
|
||||
});
|
||||
|
||||
it('does not double-encode a cmd that already contains percent sequences', () => {
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'create_link',
|
||||
cmd: 'auto http://host/s/a%3Ab.m3u8?sig=x%2Fy',
|
||||
});
|
||||
|
||||
expect(fullUrl).not.toContain('%25');
|
||||
// After the portal's single decode, pre-encoded sequences resolve —
|
||||
// exactly what it would receive from a real STB.
|
||||
expect(portalVisibleValue(cmdWireValue(fullUrl))).toBe(
|
||||
'auto http://host/s/a:b.m3u8?sig=x/y'
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks query-parameter injection through cmd without losing data', () => {
|
||||
const maliciousCmd =
|
||||
'http://host/ch/1?x=1&action=do_evil&mac=00:00:00:00:00:00#frag';
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'create_link',
|
||||
type: 'itv',
|
||||
cmd: maliciousCmd,
|
||||
});
|
||||
|
||||
const params = new URL(fullUrl).searchParams;
|
||||
expect(params.getAll('action')).toEqual(['create_link']);
|
||||
expect(params.get('mac')).toBeNull();
|
||||
expect(params.get('x')).toBeNull();
|
||||
expect(fullUrl).not.toContain('#');
|
||||
// The dangerous characters are escaped, not stripped: the portal
|
||||
// still receives the full original cmd string after one decode.
|
||||
expect(portalVisibleValue(cmdWireValue(fullUrl))).toBe(maliciousCmd);
|
||||
});
|
||||
|
||||
it('keeps full encoding for non-cmd params', () => {
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'get_profile',
|
||||
metrics: '{"mac":"00:1A:79:AA:BB:CC"}',
|
||||
});
|
||||
|
||||
expect(fullUrl).toContain(
|
||||
'metrics=%7B%22mac%22%3A%2200%3A1A%3A79%3AAA%3ABB%3ACC%22%7D'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends JsHttpRequest only when missing', () => {
|
||||
const withoutIt = buildStalkerRequestUrl(PORTAL, { action: 'x' });
|
||||
expect(withoutIt.match(/JsHttpRequest/g)).toHaveLength(1);
|
||||
|
||||
const withIt = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'x',
|
||||
JsHttpRequest: '1-xml',
|
||||
});
|
||||
expect(withIt.match(/JsHttpRequest/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops any query string carried by the portal URL itself', () => {
|
||||
const fullUrl = buildStalkerRequestUrl(
|
||||
'http://portal.example/portal.php?stale=1',
|
||||
{ action: 'handshake' }
|
||||
);
|
||||
|
||||
expect(fullUrl).toBe(
|
||||
'http://portal.example/portal.php?action=handshake&JsHttpRequest=1-xml'
|
||||
);
|
||||
});
|
||||
|
||||
it('emits URLs whose bytes survive WHATWG re-parsing (the axios transport)', () => {
|
||||
const cmds = [
|
||||
'ffrt3 http://host/ch/123',
|
||||
'auto http://host/ch/123?token=a%3Ab c',
|
||||
'/media/12345.mpg',
|
||||
'x&y=z#w;v',
|
||||
'auto http://host/канал/1',
|
||||
];
|
||||
|
||||
for (const cmd of cmds) {
|
||||
const fullUrl = buildStalkerRequestUrl(PORTAL, {
|
||||
action: 'create_link',
|
||||
cmd,
|
||||
});
|
||||
expect(new URL(fullUrl).toString()).toBe(fullUrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { encodeStalkerCmdValue } from './stalker-cmd-encoding.util';
|
||||
|
||||
/**
|
||||
* Builds the full Stalker portal request URL from an already-validated portal
|
||||
* URL and the prepared request params. Shared by the Electron main process
|
||||
* and the web-backend `/stalker` proxy so both transports emit the exact same
|
||||
* wire format.
|
||||
*
|
||||
* The query string is assembled manually because the two parameter classes
|
||||
* need different encodings:
|
||||
*
|
||||
* - `cmd` uses the minimal reference encoding (`encodeStalkerCmdValue`): a
|
||||
* real MAG sends cmd unencoded and the portal decodes the query exactly
|
||||
* once, so pre-encoded sequences (`%3A`) must pass through untouched while
|
||||
* `&`/`#`/`;` are still escaped so a malicious portal cannot append query
|
||||
* parameters through cmd.
|
||||
* - every other param is fully `encodeURIComponent`-encoded.
|
||||
*
|
||||
* Any query string on the portal URL itself is intentionally dropped (the
|
||||
* request params are the complete query), matching long-standing behavior.
|
||||
*/
|
||||
export function buildStalkerRequestUrl(
|
||||
url: string,
|
||||
requestParams: Record<string, string | number>
|
||||
): string {
|
||||
const urlObject = new URL(url);
|
||||
const queryParts: string[] = [];
|
||||
|
||||
Object.entries(requestParams).forEach(([key, value]) => {
|
||||
if (key === 'cmd') {
|
||||
queryParts.push(`${key}=${encodeStalkerCmdValue(String(value))}`);
|
||||
} else {
|
||||
queryParts.push(`${key}=${encodeURIComponent(String(value))}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Always add JsHttpRequest parameter if not present (required by Stalker API)
|
||||
if (!requestParams['JsHttpRequest']) {
|
||||
queryParts.push('JsHttpRequest=1-xml');
|
||||
}
|
||||
|
||||
return `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { isStalkerStreamCredentialSafe } from './stalker-stream-profile.util';
|
||||
|
||||
describe('isStalkerStreamCredentialSafe', () => {
|
||||
const PORTAL = 'http://portal.example/stalker_portal/c/';
|
||||
|
||||
it('accepts a stream on the exact portal origin', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://portal.example/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a same-host stream on a different port', () => {
|
||||
// The #1158 class: panels routinely serve streams from :8080 next to
|
||||
// the portal on :80, and those streams are gated on the portal cookie.
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://portal.example:8080/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a same-host scheme upgrade (http portal → https stream)', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'https://portal.example/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an https→http downgrade on the same host', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
'https://portal.example/stalker_portal/c/',
|
||||
'http://portal.example/live/ch1.ts'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a different host', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://cdn.other.example/live/ch1.ts'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('compares hostnames case-insensitively', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(
|
||||
PORTAL,
|
||||
'http://PORTAL.example:8080/live/ch1.ts'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-HTTP(S) stream schemes', () => {
|
||||
expect(
|
||||
isStalkerStreamCredentialSafe(PORTAL, 'rtsp://portal.example/ch1')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on missing or unparseable URLs', () => {
|
||||
expect(isStalkerStreamCredentialSafe(PORTAL, undefined)).toBe(false);
|
||||
expect(isStalkerStreamCredentialSafe(PORTAL, '')).toBe(false);
|
||||
expect(isStalkerStreamCredentialSafe(undefined, PORTAL)).toBe(false);
|
||||
expect(isStalkerStreamCredentialSafe(PORTAL, 'not a url')).toBe(false);
|
||||
expect(isStalkerStreamCredentialSafe('not a url', PORTAL)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Decides whether a resolved Stalker stream URL may carry the portal's
|
||||
* credentials (mac cookie, Bearer token, serial-number headers).
|
||||
*
|
||||
* Both the renderer playback-header builder and the Electron main-process
|
||||
* playback-context fallback classify streams with this single predicate; if
|
||||
* the two ever disagreed, `isStalkerDirectStreamProfile` in the external
|
||||
* player path would silently discard the caller's credentialed headers.
|
||||
*
|
||||
* The rule mirrors the transport-security carve-out of the validated
|
||||
* redirect layer (docs/architecture/electron-security.md): IPTV portals
|
||||
* routinely hand out stream URLs on the same host but a different port or an
|
||||
* upgraded scheme, and losing the session cookie/token there breaks playback
|
||||
* outright (#1158, #849, #910). A different host would hand provider
|
||||
* credentials to a third party, and an https→http downgrade would replay a
|
||||
* TLS-obtained session in cleartext — both stay credential-free.
|
||||
*/
|
||||
export function isStalkerStreamCredentialSafe(
|
||||
portalUrl: string | undefined | null,
|
||||
streamUrl: string | undefined | null
|
||||
): boolean {
|
||||
if (!portalUrl || !streamUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let portal: URL;
|
||||
let stream: URL;
|
||||
try {
|
||||
portal = new URL(portalUrl);
|
||||
stream = new URL(streamUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stream.protocol !== 'http:' && stream.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (portal.hostname.toLowerCase() !== stream.hostname.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(portal.protocol === 'https:' && stream.protocol === 'http:');
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
extractXtreamCredentialsFromUrl,
|
||||
normalizeXtreamServerUrl,
|
||||
resolveXtreamPortalExpiration,
|
||||
resolveXtreamPortalStatus,
|
||||
} from './xtream-portal.utils';
|
||||
|
||||
@@ -104,4 +105,39 @@ describe('xtream portal utilities', () => {
|
||||
).toBe('expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveXtreamPortalExpiration', () => {
|
||||
it('parses numeric-string exp_date into unix seconds', () => {
|
||||
expect(
|
||||
resolveXtreamPortalExpiration({
|
||||
user_info: { exp_date: '1790000000' },
|
||||
})
|
||||
).toBe(1_790_000_000);
|
||||
});
|
||||
|
||||
it('treats unlimited and absent expirations as null', () => {
|
||||
expect(
|
||||
resolveXtreamPortalExpiration({
|
||||
user_info: { exp_date: '0' },
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveXtreamPortalExpiration({
|
||||
user_info: { exp_date: '' },
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveXtreamPortalExpiration({ user_info: {} })
|
||||
).toBeNull();
|
||||
expect(resolveXtreamPortalExpiration(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-numeric exp_date values', () => {
|
||||
expect(
|
||||
resolveXtreamPortalExpiration({
|
||||
user_info: { exp_date: 'never' },
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,17 @@ export function resolveXtreamPortalStatus(
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the account expiration from an Xtream `get_account_info`-style
|
||||
* response as unix seconds. Returns null when the portal doesn't report one
|
||||
* (unlimited accounts answer with empty/zero/absent `exp_date`).
|
||||
*/
|
||||
export function resolveXtreamPortalExpiration(
|
||||
response: XtreamPortalStatusResponseLike | null | undefined
|
||||
): number | null {
|
||||
return parseXtreamExpiration(response?.user_info?.exp_date);
|
||||
}
|
||||
|
||||
function normalizeAuthValue(
|
||||
value: boolean | number | string | null | undefined
|
||||
): boolean | null {
|
||||
|
||||
@@ -542,6 +542,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A phone cannot carry the poster and the details side by side: the details
|
||||
// column drops to ~200px, and the action buttons are then squeezed below
|
||||
// their own labels ("Play" clipped down to its icon). Stack them instead.
|
||||
@media (max-width: 640px) {
|
||||
.hero__content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 140px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.details__title {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animations
|
||||
// ============================================================================
|
||||
@@ -587,3 +607,14 @@ ngx-skeleton-loader {
|
||||
min-height: 330px; // Fallback
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
// After the rule above so it wins the cascade at equal specificity: that
|
||||
// 330px skeleton fallback is sized for the desktop poster width, while the
|
||||
// stacked phone hero renders the poster 140px wide — its aspect-ratio height
|
||||
// is ~210px, and the leftover minimum was pure empty space pushing the title
|
||||
// and actions ~120px down on every loaded detail page.
|
||||
@media (max-width: 640px) {
|
||||
.poster {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,5 @@ export * from './lib/portal-inline-player/up-next-rail.util';
|
||||
export * from './lib/vjs-player/vjs-player.component';
|
||||
export * from './lib/video-player/sidebar/sidebar.component';
|
||||
export * from './lib/vod-details/vod-details.component';
|
||||
export * from './lib/web-player-view/electron-stream-headers.service';
|
||||
export * from './lib/web-player-view/web-player-view.component';
|
||||
@@ -122,6 +122,21 @@ describe('AudioPlayerComponent', () => {
|
||||
expect(component.volume()).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores keyboard shortcuts while inside an inert region', () => {
|
||||
createComponent();
|
||||
component.setVolume(0.5);
|
||||
// e.g. the workspace's phone context drawer marks the route content
|
||||
// inert while open; document-level key listeners still fire, so the
|
||||
// player must opt out itself.
|
||||
fixture.nativeElement.setAttribute('inert', '');
|
||||
|
||||
const upEvent = createKeyboardEvent('ArrowUp');
|
||||
component.handleKeyboard(upEvent);
|
||||
|
||||
expect(upEvent.preventDefault).not.toHaveBeenCalled();
|
||||
expect(component.volume()).toBe(0.5);
|
||||
});
|
||||
|
||||
it('restores the previous volume when unmuting', () => {
|
||||
const audio = createComponent();
|
||||
component.setVolume(0.65);
|
||||
|
||||
@@ -248,6 +248,12 @@ export class AudioPlayerComponent {
|
||||
)
|
||||
return;
|
||||
|
||||
// Inside an inert region (e.g. behind the workspace's phone context
|
||||
// drawer) the player is out of the interaction model: inert strips
|
||||
// pointer and Tab access, but this document-level listener still
|
||||
// fires, so it must opt out itself.
|
||||
if (this.hostEl.nativeElement.closest('[inert]')) return;
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.setVolume(this.volume() + 0.05);
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Subject } from 'rxjs';
|
||||
import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service';
|
||||
|
||||
describe('EmbeddedMpvOverlayVisibilityService', () => {
|
||||
let service: EmbeddedMpvOverlayVisibilityService;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: OverlayContainer,
|
||||
useValue: { getContainerElement: () => container },
|
||||
},
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: {
|
||||
openDialogs: [],
|
||||
afterOpened: new Subject(),
|
||||
afterAllClosed: new Subject(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(EmbeddedMpvOverlayVisibilityService);
|
||||
});
|
||||
|
||||
it('starts with no active overlay', () => {
|
||||
expect(service.overlayActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a registered external modal surface like an open dialog', () => {
|
||||
// e.g. the workspace's phone context drawer — a plain DOM panel the
|
||||
// CDK-container observer can never see, but which must hide the
|
||||
// native-view video that paints above DOM stacking.
|
||||
const release = service.acquireExternalModalSurface();
|
||||
expect(service.overlayActive()).toBe(true);
|
||||
|
||||
release();
|
||||
expect(service.overlayActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays active until every external surface is released', () => {
|
||||
const releaseFirst = service.acquireExternalModalSurface();
|
||||
const releaseSecond = service.acquireExternalModalSurface();
|
||||
|
||||
releaseFirst();
|
||||
expect(service.overlayActive()).toBe(true);
|
||||
|
||||
releaseSecond();
|
||||
expect(service.overlayActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a double release', () => {
|
||||
const releaseFirst = service.acquireExternalModalSurface();
|
||||
const releaseSecond = service.acquireExternalModalSurface();
|
||||
|
||||
releaseFirst();
|
||||
releaseFirst();
|
||||
|
||||
expect(service.overlayActive()).toBe(true);
|
||||
releaseSecond();
|
||||
expect(service.overlayActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
+32
-4
@@ -9,6 +9,7 @@ export class EmbeddedMpvOverlayVisibilityService {
|
||||
private readonly overlayContainer = inject(OverlayContainer);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private observer: MutationObserver | null = null;
|
||||
private externalModalSurfaces = 0;
|
||||
|
||||
constructor() {
|
||||
this.dialog.afterOpened.subscribe(() => this.recompute());
|
||||
@@ -26,15 +27,42 @@ export class EmbeddedMpvOverlayVisibilityService {
|
||||
this.recompute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a modal surface that does not live in the CDK overlay
|
||||
* container — e.g. the workspace's phone context drawer, a plain
|
||||
* translate-in DOM panel. The native-view video surface paints above
|
||||
* DOM stacking, so any such surface must hide it exactly like a
|
||||
* Material dialog does. Returns a release function; releasing twice is
|
||||
* a no-op so effect cleanups can call it defensively.
|
||||
*/
|
||||
acquireExternalModalSurface(): () => void {
|
||||
this.externalModalSurfaces += 1;
|
||||
this.recompute();
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
this.externalModalSurfaces -= 1;
|
||||
this.recompute();
|
||||
};
|
||||
}
|
||||
|
||||
private recompute(): void {
|
||||
const dialogOpen = this.dialog.openDialogs.length > 0;
|
||||
const backdropPresent =
|
||||
this.overlayContainer
|
||||
.getContainerElement()
|
||||
.querySelector('.cdk-overlay-backdrop') !== null;
|
||||
const next = dialogOpen || backdropPresent;
|
||||
if (this.overlayActive() !== next) {
|
||||
this.overlayActive.set(next);
|
||||
}
|
||||
const next =
|
||||
dialogOpen || backdropPresent || this.externalModalSurfaces > 0;
|
||||
// Plain set, no read-back guard: signals already skip notification
|
||||
// for equal values, and reading overlayActive here would silently
|
||||
// register it as a dependency of any reactive context that calls
|
||||
// into this service (acquireExternalModalSurface from an effect
|
||||
// looped forever on exactly that).
|
||||
this.overlayActive.set(next);
|
||||
}
|
||||
}
|
||||
@@ -402,6 +402,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
isAvailable: () =>
|
||||
this.legacyInteractions.isAvailable() &&
|
||||
!this.overlayVisibility.overlayActive(),
|
||||
hostElement: () => this.playerRoot()?.nativeElement ?? null,
|
||||
arrowKeysBlocked: () => this.menus.dockPanelOpen(),
|
||||
onEscape: () => this.legacyInteractions.closePopovers(),
|
||||
togglePaused: () => void this.togglePaused(),
|
||||
|
||||
@@ -30,6 +30,31 @@ describe('EmbeddedMpvShortcuts', () => {
|
||||
shortcuts.detach();
|
||||
});
|
||||
|
||||
it('ignores every shortcut while the host sits inside an inert region', () => {
|
||||
const inertRegion = document.createElement('div');
|
||||
inertRegion.setAttribute('inert', '');
|
||||
const host = document.createElement('div');
|
||||
inertRegion.appendChild(host);
|
||||
document.body.appendChild(inertRegion);
|
||||
(
|
||||
handlers as unknown as { hostElement: () => HTMLElement }
|
||||
).hostElement = () => host;
|
||||
|
||||
try {
|
||||
expect(dispatchKey(' ')).toBe(false);
|
||||
expect(dispatchKey('ArrowUp')).toBe(false);
|
||||
expect(dispatchKey('m')).toBe(false);
|
||||
dispatchKey('Escape');
|
||||
|
||||
expect(handlers.togglePaused).not.toHaveBeenCalled();
|
||||
expect(handlers.adjustVolume).not.toHaveBeenCalled();
|
||||
expect(handlers.toggleMute).not.toHaveBeenCalled();
|
||||
expect(handlers.onEscape).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
inertRegion.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards playback, seek, volume, and fullscreen shortcuts', () => {
|
||||
expect(dispatchKey(' ')).toBe(true);
|
||||
expect(dispatchKey('ArrowLeft')).toBe(true);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export interface EmbeddedMpvShortcutHandlers {
|
||||
isAvailable: () => boolean;
|
||||
/**
|
||||
* The player's host element, used to opt out of every shortcut while an
|
||||
* ancestor is `inert` (e.g. behind the workspace's phone context
|
||||
* drawer): inert strips pointer and Tab access, but this document-level
|
||||
* listener still fires, so shortcuts must check it themselves.
|
||||
*/
|
||||
hostElement?: () => HTMLElement | null;
|
||||
/**
|
||||
* While true, arrow keys stop seeking/adjusting volume — an open dock
|
||||
* chip panel owns them for chip navigation instead.
|
||||
@@ -37,6 +44,13 @@ export class EmbeddedMpvShortcuts {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inside an inert region the player is outside the interaction
|
||||
// model entirely — the modal surface above it owns the keyboard,
|
||||
// including Escape.
|
||||
if (handlers.hostElement?.()?.closest('[inert]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
handlers.onEscape();
|
||||
return;
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL);
|
||||
});
|
||||
|
||||
it('passes channel headers and stream URL to Electron header overrides', () => {
|
||||
it('does not configure Electron header overrides itself — WebPlayerViewComponent owns them', () => {
|
||||
jest.spyOn(
|
||||
component.videoPlayer.nativeElement,
|
||||
'load'
|
||||
@@ -145,39 +145,9 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
url: 'https://stream.example/video.mp4',
|
||||
});
|
||||
|
||||
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
|
||||
'ChannelAgent/1.0',
|
||||
'https://portal.example/referrer',
|
||||
'https://stream.example/video.mp4'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears Electron header overrides for channels without custom headers', () => {
|
||||
jest.spyOn(
|
||||
component.videoPlayer.nativeElement,
|
||||
'load'
|
||||
).mockImplementation(() => undefined);
|
||||
jest.spyOn(
|
||||
component.videoPlayer.nativeElement,
|
||||
'play'
|
||||
).mockResolvedValue(undefined);
|
||||
|
||||
component.playChannel({
|
||||
...TEST_CHANNEL,
|
||||
http: {
|
||||
'user-agent': '',
|
||||
origin: '',
|
||||
referrer: '',
|
||||
},
|
||||
radio: 'false',
|
||||
url: 'https://stream.example/video.mp4',
|
||||
});
|
||||
|
||||
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
|
||||
'',
|
||||
'',
|
||||
'https://stream.example/video.mp4'
|
||||
);
|
||||
// A second three-header call from here would overwrite the richer
|
||||
// scoped override (incl. Cookie/Authorization) the host configured.
|
||||
expect(electronApi.setUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces and reloads native video sources when switching episodes', () => {
|
||||
|
||||
@@ -187,18 +187,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const url = channel.url + (channel.epgParams ?? '');
|
||||
const extension = getPlaybackMediaExtensionFromUrl(channel.url);
|
||||
|
||||
void window.electron
|
||||
?.setUserAgent(
|
||||
channel.http?.['user-agent'],
|
||||
channel.http?.referrer,
|
||||
channel.url
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
'[HtmlVideoPlayer] Failed to configure Electron request headers:',
|
||||
error
|
||||
);
|
||||
});
|
||||
// The scoped Electron header override is owned by
|
||||
// WebPlayerViewComponent, which configures the full header set
|
||||
// (incl. portal Cookie/Authorization) before this component
|
||||
// receives the channel. Re-issuing the three-header call here
|
||||
// would overwrite that richer override.
|
||||
|
||||
if (extension === 'mpd') {
|
||||
debugHtmlPlayer(
|
||||
|
||||
@@ -56,6 +56,46 @@ describe('ControlsShortcuts', () => {
|
||||
expect(handlers.toggleMute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores every shortcut while the host sits inside an inert region', () => {
|
||||
const inertRegion = document.createElement('div');
|
||||
inertRegion.setAttribute('inert', '');
|
||||
const host = document.createElement('div');
|
||||
inertRegion.appendChild(host);
|
||||
document.body.appendChild(inertRegion);
|
||||
(
|
||||
handlers as unknown as { hostElement: () => HTMLElement }
|
||||
).hostElement = () => host;
|
||||
|
||||
try {
|
||||
expect(dispatchKey(' ')).toBe(false);
|
||||
expect(dispatchKey('ArrowUp')).toBe(false);
|
||||
expect(dispatchKey('m')).toBe(false);
|
||||
dispatchKey('Escape');
|
||||
|
||||
expect(handlers.togglePaused).not.toHaveBeenCalled();
|
||||
expect(handlers.adjustVolume).not.toHaveBeenCalled();
|
||||
expect(handlers.toggleMute).not.toHaveBeenCalled();
|
||||
expect(handlers.onEscape).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
inertRegion.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('handles shortcuts when the host is not inside an inert region', () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
(
|
||||
handlers as unknown as { hostElement: () => HTMLElement }
|
||||
).hostElement = () => host;
|
||||
|
||||
try {
|
||||
expect(dispatchKey(' ')).toBe(true);
|
||||
expect(handlers.togglePaused).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not consume keys for unsupported actions', () => {
|
||||
handlers.canTogglePaused.mockReturnValue(false);
|
||||
handlers.canSeek.mockReturnValue(false);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export interface ControlsShortcutHandlers {
|
||||
isAvailable: () => boolean;
|
||||
/**
|
||||
* The player's host element, used to opt out of global shortcuts while
|
||||
* an ancestor is `inert` (e.g. behind the workspace's phone context
|
||||
* drawer): inert strips pointer and Tab access, but document-level
|
||||
* keydown listeners still fire, so shortcuts must check it themselves.
|
||||
*/
|
||||
hostElement?: () => HTMLElement | null;
|
||||
canTogglePaused: () => boolean;
|
||||
canSeek: () => boolean;
|
||||
canAdjustVolume: () => boolean;
|
||||
@@ -78,6 +85,14 @@ export class ControlsShortcuts {
|
||||
return;
|
||||
}
|
||||
|
||||
// A player inside an inert region is outside the interaction model
|
||||
// entirely — a modal surface above it owns the keyboard. This also
|
||||
// covers Escape: the modal decides what Escape means, not the
|
||||
// obscured player.
|
||||
if (handlers.hostElement?.()?.closest('[inert]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
handlers.onEscape();
|
||||
return;
|
||||
|
||||
@@ -140,6 +140,7 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
constructor() {
|
||||
this.shortcuts.attach({
|
||||
isAvailable: () => this.shortcutsEnabled() && this.showControls(),
|
||||
hostElement: () => this.host,
|
||||
canTogglePaused: () => this.canTogglePlay(),
|
||||
canSeek: () => this.capabilities().seek && this.state().canSeek,
|
||||
canAdjustVolume: () => this.capabilities().volume,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
||||
|
||||
const STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
|
||||
const GATED_PLAYBACK = {
|
||||
streamUrl: STREAM_URL,
|
||||
title: 'Gated Channel',
|
||||
headers: {
|
||||
'User-Agent': 'MAG250',
|
||||
Referer: 'http://portal.example',
|
||||
Cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
Authorization: 'Bearer TOKEN123',
|
||||
},
|
||||
};
|
||||
|
||||
describe('ElectronStreamHeadersService', () => {
|
||||
let service: ElectronStreamHeadersService;
|
||||
let setUserAgent: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setUserAgent = jest.fn().mockResolvedValue(true);
|
||||
(window as unknown as { electron?: unknown }).electron = {
|
||||
setUserAgent,
|
||||
};
|
||||
service = TestBed.inject(ElectronStreamHeadersService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
});
|
||||
|
||||
it('returns null without an Electron bridge (PWA)', () => {
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
|
||||
expect(service.apply(GATED_PLAYBACK)).toBeNull();
|
||||
expect(setUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts the full header set and scopes it to the stream URL', async () => {
|
||||
await expect(service.apply(GATED_PLAYBACK)).resolves.toBe(true);
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
STREAM_URL,
|
||||
{
|
||||
authorization: 'Bearer TOKEN123',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers explicit userAgent/referer fields over the headers map', async () => {
|
||||
await service.apply({
|
||||
...GATED_PLAYBACK,
|
||||
userAgent: 'ExplicitAgent/1.0',
|
||||
referer: 'http://explicit.example',
|
||||
});
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'ExplicitAgent/1.0',
|
||||
'http://explicit.example',
|
||||
STREAM_URL,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the credentials object when the playback carries none', async () => {
|
||||
await service.apply({
|
||||
streamUrl: 'https://example.com/live/plain.m3u8',
|
||||
title: 'Plain Channel',
|
||||
userAgent: 'PlainAgent/1.0',
|
||||
});
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'PlainAgent/1.0',
|
||||
undefined,
|
||||
'https://example.com/live/plain.m3u8',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a superseded apply as not current', async () => {
|
||||
const resolvers: Array<() => void> = [];
|
||||
setUserAgent.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) =>
|
||||
resolvers.push(() => resolve(true))
|
||||
)
|
||||
);
|
||||
|
||||
const first = service.apply(GATED_PLAYBACK);
|
||||
const second = service.apply({
|
||||
...GATED_PLAYBACK,
|
||||
streamUrl: 'http://portal.example:8080/live/ch2.ts',
|
||||
});
|
||||
|
||||
resolvers[0]();
|
||||
resolvers[1]();
|
||||
|
||||
await expect(first).resolves.toBe(false);
|
||||
await expect(second).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('clears the override only while the caller still owns the slot', async () => {
|
||||
await service.apply(GATED_PLAYBACK);
|
||||
setUserAgent.mockClear();
|
||||
|
||||
// A stale consumer (e.g. a destroyed component) must not wipe the
|
||||
// override a newer playback configured.
|
||||
service.clear('http://portal.example:8080/other-stream.ts');
|
||||
expect(setUserAgent).not.toHaveBeenCalled();
|
||||
|
||||
service.clear(STREAM_URL);
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
STREAM_URL
|
||||
);
|
||||
});
|
||||
|
||||
it('does not clear twice for the same stream', async () => {
|
||||
await service.apply(GATED_PLAYBACK);
|
||||
service.clear(STREAM_URL);
|
||||
setUserAgent.mockClear();
|
||||
|
||||
service.clear(STREAM_URL);
|
||||
expect(setUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves instead of throwing when the bridge rejects', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
|
||||
/* silence */
|
||||
});
|
||||
setUserAgent.mockRejectedValue(new Error('ipc down'));
|
||||
|
||||
await expect(service.apply(GATED_PLAYBACK)).resolves.toBe(true);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
|
||||
function getHeaderValue(
|
||||
headers: ResolvedPortalPlayback['headers'] | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matchingKey = Object.keys(headers).find(
|
||||
(key) => key.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
return matchingKey ? headers[matchingKey] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single owner of the scoped Electron request-header override for built-in
|
||||
* playback. There is exactly one scoped override slot in the main process, so
|
||||
* every surface that plays a stream inline goes through this service:
|
||||
* `WebPlayerViewComponent` for the web video players, and the Stalker live
|
||||
* layout for the dedicated radio audio player, which never mounts a
|
||||
* `WebPlayerViewComponent` at all.
|
||||
*
|
||||
* `apply()` extracts the full header set from the resolved playback —
|
||||
* including the portal Cookie/Authorization that auth-gated streams require —
|
||||
* and hands it to the main process scoped to the stream's URL. A playback
|
||||
* without custom headers deliberately clears the previous scoped override so
|
||||
* stale headers never leak onto the next stream. `clear()` releases the slot
|
||||
* only while the caller's stream still owns it, so a consumer being destroyed
|
||||
* cannot wipe the override a newer consumer just configured.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElectronStreamHeadersService {
|
||||
/** Guards against a superseded apply resolving its header IPC late. */
|
||||
private syncSequence = 0;
|
||||
/** Stream URL the currently configured override belongs to. */
|
||||
private currentScopeUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* Configures the scoped override for this playback. Resolves `true` when
|
||||
* this apply is still the newest one at the time the main process
|
||||
* acknowledged it — callers must only hand the source to a player on
|
||||
* `true`, so the first media request already carries the headers. Returns
|
||||
* null when there is no Electron bridge (PWA), where sources apply
|
||||
* synchronously and no override exists.
|
||||
*/
|
||||
apply(playback: ResolvedPortalPlayback): Promise<boolean> | null {
|
||||
// Feature-detect the bridge method rather than the bridge object:
|
||||
// partial bridges (tests, older preloads) must behave like the PWA
|
||||
// instead of throwing inside a playback flow.
|
||||
if (typeof window.electron?.setUserAgent !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sequence = ++this.syncSequence;
|
||||
const userAgent =
|
||||
playback.userAgent ??
|
||||
getHeaderValue(playback.headers, 'User-Agent');
|
||||
const referer =
|
||||
playback.referer ?? getHeaderValue(playback.headers, 'Referer');
|
||||
const cookie = getHeaderValue(playback.headers, 'Cookie');
|
||||
const authorization = getHeaderValue(playback.headers, 'Authorization');
|
||||
|
||||
this.currentScopeUrl = playback.streamUrl;
|
||||
return window.electron
|
||||
.setUserAgent(
|
||||
userAgent,
|
||||
referer,
|
||||
playback.streamUrl,
|
||||
cookie || authorization ? { authorization, cookie } : undefined
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
'[ElectronStreamHeaders] Failed to configure request headers:',
|
||||
error
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.then(() => sequence === this.syncSequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the scoped override — portal credentials must not outlive the
|
||||
* playback session that needed them. No-ops when another stream has taken
|
||||
* ownership of the slot since, and always in the PWA.
|
||||
*/
|
||||
clear(streamUrl: string | null): void {
|
||||
if (
|
||||
typeof window.electron?.setUserAgent !== 'function' ||
|
||||
streamUrl === null ||
|
||||
this.currentScopeUrl !== streamUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncSequence += 1;
|
||||
this.currentScopeUrl = null;
|
||||
void window.electron
|
||||
.setUserAgent(undefined, undefined, streamUrl)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,68 @@
|
||||
@if (selectedPlayer() === 'videojs') {
|
||||
@defer (on immediate) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
<!-- The source hand-off is deferred until the scoped Electron header
|
||||
override is configured; mounting the player before `vjsOptions`
|
||||
exists would fire a source-less (or credential-less) first load. -->
|
||||
@if (vjsOptions) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'html5') {
|
||||
@defer (on immediate) {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
@if (channel) {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'artplayer') {
|
||||
@defer (on immediate) {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
@if (channel) {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
|
||||
@@ -913,6 +913,122 @@ describe('WebPlayerViewComponent', () => {
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
describe('Electron scoped header override ownership', () => {
|
||||
const GATED_STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
|
||||
const GATED_PLAYBACK = {
|
||||
streamUrl: GATED_STREAM_URL,
|
||||
title: 'Gated Channel',
|
||||
isLive: true,
|
||||
headers: {
|
||||
'User-Agent': 'MAG250',
|
||||
Referer: 'http://portal.example',
|
||||
Cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
Authorization: 'Bearer TOKEN123',
|
||||
},
|
||||
};
|
||||
let setUserAgent: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setUserAgent = jest.fn().mockResolvedValue(true);
|
||||
(window as unknown as { electron?: unknown }).electron = {
|
||||
setUserAgent,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
});
|
||||
|
||||
it('configures the full header set — incl. credentials — before handing the source to the player', async () => {
|
||||
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
// The source is handed over only after the override IPC resolves,
|
||||
// so the first media request already carries the credentials.
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'MAG250',
|
||||
'http://portal.example',
|
||||
GATED_STREAM_URL,
|
||||
{
|
||||
authorization: 'Bearer TOKEN123',
|
||||
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
|
||||
}
|
||||
);
|
||||
expect(component.channel).toBeUndefined();
|
||||
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.channel.url).toBe(GATED_STREAM_URL);
|
||||
});
|
||||
|
||||
it('omits the credentials object when the playback carries none', async () => {
|
||||
fixture.componentRef.setInput('playback', {
|
||||
streamUrl: 'https://example.com/live/plain.m3u8',
|
||||
title: 'Plain Channel',
|
||||
userAgent: 'PlainAgent/1.0',
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
'PlainAgent/1.0',
|
||||
undefined,
|
||||
'https://example.com/live/plain.m3u8',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('applies only the newest playback when a switch supersedes a pending header IPC', async () => {
|
||||
const resolvers: Array<() => void> = [];
|
||||
setUserAgent.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) =>
|
||||
resolvers.push(() => resolve(true))
|
||||
)
|
||||
);
|
||||
|
||||
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
|
||||
fixture.detectChanges();
|
||||
fixture.componentRef.setInput('playback', {
|
||||
streamUrl: 'http://portal.example:8080/live/ch2.ts',
|
||||
title: 'Next Channel',
|
||||
isLive: true,
|
||||
headers: GATED_PLAYBACK.headers,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// The stale IPC completion must not hand the old source over.
|
||||
resolvers[0]();
|
||||
await fixture.whenStable();
|
||||
expect(component.channel).toBeUndefined();
|
||||
|
||||
resolvers[1]();
|
||||
await fixture.whenStable();
|
||||
expect(component.channel.url).toBe(
|
||||
'http://portal.example:8080/live/ch2.ts'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the scoped override on destroy so credentials do not outlive playback', async () => {
|
||||
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
setUserAgent.mockClear();
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
expect(setUserAgent).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
GATED_STREAM_URL
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
Signal,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
||||
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
||||
import {
|
||||
getDiagnosticCodecHint,
|
||||
getDiagnosticDescriptionKey,
|
||||
@@ -89,7 +91,7 @@ function resolveWebPlayerSharedControls(): boolean {
|
||||
],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class WebPlayerViewComponent {
|
||||
export class WebPlayerViewComponent implements OnDestroy {
|
||||
storage = inject(StorageMap);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
@@ -221,15 +223,58 @@ export class WebPlayerViewComponent {
|
||||
});
|
||||
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
|
||||
|
||||
/** Stream URL the currently configured Electron header override belongs to. */
|
||||
private headerScopeStreamUrl: string | null = null;
|
||||
private readonly streamHeaders = inject(ElectronStreamHeadersService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// Track player changes so stale browser diagnostics are cleared on switch.
|
||||
this.selectedPlayer();
|
||||
|
||||
const playback = this.resolvedPlayback();
|
||||
const isLive = this.resolvedIsLive();
|
||||
this.playbackDiagnostic.set(null);
|
||||
this.applyPlayback(playback, isLive);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Portal credentials must not outlive the playback session that
|
||||
// needed them: dropping the scoped override here keeps only the
|
||||
// playlist-level (unscoped) User-Agent/Referer defaults active. The
|
||||
// service no-ops if a newer consumer already owns the override slot.
|
||||
this.streamHeaders.clear(this.headerScopeStreamUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the scoped Electron request headers BEFORE the stream source
|
||||
* is handed to a player, so the very first media request already carries
|
||||
* them — an auth-gated portal stream answers 403 without its
|
||||
* Cookie/Authorization, and several engines treat that first failure as
|
||||
* fatal. In the PWA there is no header bridge and the source applies
|
||||
* synchronously, exactly as before.
|
||||
*/
|
||||
private applyPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
isLive: boolean
|
||||
): void {
|
||||
const headerSync = this.streamHeaders.apply(playback);
|
||||
this.headerScopeStreamUrl = playback.streamUrl;
|
||||
const handOff = (): void => {
|
||||
this.setChannel(playback);
|
||||
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
|
||||
this.setVjsOptions(playback.streamUrl, isLive);
|
||||
};
|
||||
|
||||
if (!headerSync) {
|
||||
handOff();
|
||||
return;
|
||||
}
|
||||
|
||||
void headerSync.then((stillCurrent) => {
|
||||
if (stillCurrent) {
|
||||
handOff();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
// Shared account-info dialog stylesheet, used by both portal dialogs:
|
||||
// - libs/portal/xtream/feature/src/lib/account-info/
|
||||
// - libs/portal/stalker/feature/src/lib/stalker-account-info/
|
||||
//
|
||||
// The provider accent is injected via the `--account-dialog-accent` custom
|
||||
// property, set on :host by each consumer. Provider-specific structural
|
||||
// tweaks (e.g. the stats column count) stay in the consumer stylesheet.
|
||||
|
||||
// ─── Hero header ────────────────────────────────────────────────────────────
|
||||
.account-dialog__hero {
|
||||
margin: 0;
|
||||
padding: 20px 20px 18px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
background:
|
||||
var(--app-accent-gradient),
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
|
||||
border-bottom: 1px solid
|
||||
var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.account-dialog__hero-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-dialog__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-dialog__eyebrow-mark {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--account-dialog-accent, var(--mat-sys-primary));
|
||||
box-shadow: 0 0 0 4px
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--account-dialog-accent, var(--mat-sys-primary)) 14%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.account-dialog__eyebrow-divider {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.account-dialog__headline-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.account-dialog__title {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.account-dialog__subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
// ─── Status pills ───────────────────────────────────────────────────────────
|
||||
.account-dialog__status-cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.account-dialog__status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--app-separator);
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-dialog__status-pill--active {
|
||||
border-color: color-mix(in srgb, #34d399 30%, transparent);
|
||||
background: color-mix(in srgb, #34d399 12%, transparent);
|
||||
color: #2db36f;
|
||||
}
|
||||
|
||||
.account-dialog__status-pill--trial,
|
||||
.account-dialog__status-pill--cached {
|
||||
border-color: color-mix(in srgb, #fbbf24 28%, transparent);
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
color: #d18b09;
|
||||
}
|
||||
|
||||
// ─── Messages ───────────────────────────────────────────────────────────────
|
||||
.account-dialog__message,
|
||||
.account-dialog__loading-copy {
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.account-dialog__message {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--app-separator);
|
||||
background: var(--mat-sys-surface-container);
|
||||
line-height: 1.5;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
// ─── Hero stat cards ────────────────────────────────────────────────────────
|
||||
.account-dialog__hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-stat {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--app-widget-border, transparent);
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface-container));
|
||||
box-shadow: var(--app-widget-shadow, none);
|
||||
// Subtle inset highlight matching widget-shell pattern
|
||||
outline: 1px solid rgba(255, 255, 255, 0.04);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.account-stat__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--account-dialog-accent, var(--mat-sys-primary)) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--account-dialog-accent, var(--mat-sys-primary));
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-stat__label {
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-stat__value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.account-stat__value--warning {
|
||||
color: #d18b09;
|
||||
}
|
||||
|
||||
.account-stat__meter {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-separator);
|
||||
}
|
||||
|
||||
.account-stat__meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--account-dialog-accent, var(--mat-sys-primary));
|
||||
}
|
||||
|
||||
// ─── Content area ───────────────────────────────────────────────────────────
|
||||
.account-dialog__content {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.account-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
// ─── Detail panels ──────────────────────────────────────────────────────────
|
||||
.account-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--app-widget-border, transparent);
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
|
||||
box-shadow: var(--app-widget-shadow, none);
|
||||
outline: 1px solid rgba(255, 255, 255, 0.04);
|
||||
outline-offset: -1px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.account-panel__header {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.account-panel__header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.account-panel__eyebrow {
|
||||
margin: 0;
|
||||
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// ─── Detail rows ────────────────────────────────────────────────────────────
|
||||
.account-detail-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.account-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 140px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.account-detail-row:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.account-detail-row dt {
|
||||
margin: 0;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-detail-row dd {
|
||||
margin: 0;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.account-detail-row__value--mono {
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.account-detail-row__value--accent {
|
||||
color: var(--account-dialog-accent, var(--mat-sys-primary));
|
||||
}
|
||||
|
||||
.account-detail-row__value--positive {
|
||||
color: #2db36f;
|
||||
}
|
||||
|
||||
.account-detail-row__value--warning {
|
||||
color: #d18b09;
|
||||
}
|
||||
|
||||
// ─── Chip sections ──────────────────────────────────────────────────────────
|
||||
.account-chip-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-chip-section__label {
|
||||
margin: 0;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--account-dialog-accent, var(--mat-sys-primary)) 16%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--account-dialog-accent, var(--mat-sys-primary)) 8%,
|
||||
transparent
|
||||
);
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-chip--mono {
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.account-chip--muted {
|
||||
border-color: var(--app-separator);
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
}
|
||||
|
||||
// ─── Empty account block ────────────────────────────────────────────────────
|
||||
.account-dialog__empty-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 46ch;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Error state ────────────────────────────────────────────────────────────
|
||||
.account-dialog__error-state {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
padding: 36px 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed var(--app-separator);
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.account-dialog__error-state h3,
|
||||
.account-dialog__error-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.account-dialog__error-state p {
|
||||
max-width: 52ch;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
line-height: 1.5;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.account-dialog__error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
// ─── Skeleton loading ───────────────────────────────────────────────────────
|
||||
.account-panel--loading {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--app-separator);
|
||||
}
|
||||
|
||||
.account-skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-heading-color, var(--mat-sys-on-surface)) 10%,
|
||||
transparent
|
||||
),
|
||||
transparent
|
||||
);
|
||||
animation: account-dialog-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.account-skeleton--stat {
|
||||
min-height: 110px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.account-skeleton--heading {
|
||||
width: 54%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.account-skeleton--row {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.account-skeleton--row.short {
|
||||
width: 62%;
|
||||
}
|
||||
|
||||
// ─── Footer actions ─────────────────────────────────────────────────────────
|
||||
.account-dialog__actions {
|
||||
padding: 0 20px 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
border-top: 1px solid
|
||||
var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.account-dialog__close {
|
||||
min-width: 100px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@keyframes account-dialog-shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Responsive breakpoints ─────────────────────────────────────────────────
|
||||
@media (max-width: 980px) {
|
||||
.account-dialog__hero-stats,
|
||||
.account-dialog__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.account-dialog__hero,
|
||||
.account-dialog__content,
|
||||
.account-dialog__actions {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.account-dialog__headline-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.account-dialog__status-cluster {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.account-dialog__hero-stats,
|
||||
.account-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-detail-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@
|
||||
align-items: baseline;
|
||||
gap: $gap;
|
||||
flex: 1;
|
||||
// `flex: 1` lets this shrink to zero width, and the nowrap subtitle then
|
||||
// painted outside the box and on top of whatever sits beside it.
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@mixin standard-panel-title($font-size: 0.95rem) {
|
||||
|
||||
@@ -178,4 +178,67 @@
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phone layout ────────────────────────────────────────────────────────
|
||||
// Side by side, the 400px channel list leaves nothing for the player on a
|
||||
// phone, so the two stack instead. The width needs `!important` because
|
||||
// ResizableDirective writes the persisted desktop width inline; dragging
|
||||
// is meaningless at full width, so the handle goes too.
|
||||
@media (max-width: 640px) {
|
||||
:host {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none;
|
||||
flex-shrink: 1;
|
||||
max-height: 42vh;
|
||||
// Without a floor of its own the player's minimum can take the
|
||||
// whole body on a short screen and the list vanishes while still
|
||||
// marked expanded, leaving no way to pick another channel.
|
||||
min-height: 72px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
|
||||
&.sidebar-collapsed {
|
||||
max-height: 0;
|
||||
min-height: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
// Inside that container the guide is `flex: 0 0 <basis>` while the
|
||||
// video is `flex: 1 1 0`, so shrinking is distributed by basis and
|
||||
// the video — basis zero — absorbed none of it and collapsed. Here
|
||||
// the video keeps a floor and the guide is what gives way.
|
||||
.video-player {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.epg.epg--inline {
|
||||
flex-shrink: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The player's floor, not the list's ceiling, is what has to hold — but
|
||||
// only where the screen can afford it. A landscape phone already spends
|
||||
// 30vh on the stacked categories panel, so claiming 240px more here left
|
||||
// nothing for the channel list; there, the two panes just share what is
|
||||
// left. Keyed on height because that, not width, is what runs out.
|
||||
@media (max-width: 640px) and (min-height: 600px) {
|
||||
.content-container {
|
||||
min-height: 240px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './lib/dashboard-data.service';
|
||||
export * from './lib/dashboard-source-expiry.service';
|
||||
export * from './lib/dashboard-source-expiry.util';
|
||||
export * from './lib/dashboard-trending.service';
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Injectable, NgZone, inject, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { normalizeStoredStalkerAccountInfo } from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService, PortalStatusService } from '@iptvnator/services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
isStalkerAccountPlaylist,
|
||||
isXtreamAccountPlaylist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { SourceExpiryFacts } from './dashboard-source-expiry.util';
|
||||
|
||||
/**
|
||||
* Collects subscription-expiry facts for the dashboard's source cards.
|
||||
*
|
||||
* Xtream expirations ride on the same cached `PortalStatusService` check the
|
||||
* playlist switcher makes (30s TTL + in-flight dedup), so the dashboard adds
|
||||
* no portal round-trips beyond that shared check. Stalker expirations come
|
||||
* from the `stalkerAccountInfo` snapshot persisted at import — it lives in
|
||||
* the playlist payload (not on the meta rows), so each Stalker source costs
|
||||
* one full-playlist read, memoized until the playlist is refreshed.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardSourceExpiryService {
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly portalStatusService = inject(PortalStatusService);
|
||||
|
||||
private readonly factsState = signal<ReadonlyMap<string, SourceExpiryFacts>>(
|
||||
new Map()
|
||||
);
|
||||
/** Facts per playlist id; entries land as each source's lookup resolves. */
|
||||
readonly facts = this.factsState.asReadonly();
|
||||
|
||||
/**
|
||||
* Stalker snapshots only change on re-import/refresh; fingerprinting on
|
||||
* the playlist's update timestamp keeps dashboard revisits IPC-free.
|
||||
*/
|
||||
private readonly stalkerFactsCache = new Map<
|
||||
string,
|
||||
{ fingerprint: string; facts: SourceExpiryFacts | null }
|
||||
>();
|
||||
|
||||
private refreshRound = 0;
|
||||
|
||||
/** Refreshes facts for the given sources; prunes ids no longer present. */
|
||||
async refresh(playlists: readonly PlaylistMeta[]): Promise<void> {
|
||||
const round = ++this.refreshRound;
|
||||
const validIds = new Set(playlists.map((playlist) => playlist._id));
|
||||
|
||||
this.applyIfCurrent(round, (map) => {
|
||||
let next: Map<string, SourceExpiryFacts> | null = null;
|
||||
for (const id of map.keys()) {
|
||||
if (!validIds.has(id)) {
|
||||
next ??= new Map(map);
|
||||
next.delete(id);
|
||||
}
|
||||
}
|
||||
return next ?? map;
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
playlists.map(async (playlist) => {
|
||||
const facts = await this.loadFacts(playlist);
|
||||
this.applyIfCurrent(round, (map) => {
|
||||
if ((map.get(playlist._id) ?? null) === facts) {
|
||||
return map;
|
||||
}
|
||||
const next = new Map(map);
|
||||
if (facts === null) {
|
||||
next.delete(playlist._id);
|
||||
} else {
|
||||
next.set(playlist._id, facts);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async loadFacts(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<SourceExpiryFacts | null> {
|
||||
if (isXtreamAccountPlaylist(playlist)) {
|
||||
return this.loadXtreamFacts(playlist);
|
||||
}
|
||||
if (isStalkerAccountPlaylist(playlist)) {
|
||||
return this.loadStalkerFacts(playlist);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async loadXtreamFacts(
|
||||
playlist: PlaylistMeta & {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
): Promise<SourceExpiryFacts | null> {
|
||||
try {
|
||||
const details =
|
||||
await this.portalStatusService.checkPortalStatusDetails(
|
||||
playlist.serverUrl,
|
||||
playlist.username,
|
||||
playlist.password
|
||||
);
|
||||
// An unreachable portal is unknown, not expired — no badge.
|
||||
if (details.status === 'unavailable') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
expiresAtSeconds: details.expiresAtSeconds,
|
||||
reportedExpired: details.status === 'expired',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadStalkerFacts(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<SourceExpiryFacts | null> {
|
||||
const fingerprint = String(
|
||||
playlist.updateDate ?? playlist.importDate ?? ''
|
||||
);
|
||||
const cached = this.stalkerFactsCache.get(playlist._id);
|
||||
if (cached && cached.fingerprint === fingerprint) {
|
||||
return cached.facts;
|
||||
}
|
||||
|
||||
let facts: SourceExpiryFacts | null;
|
||||
try {
|
||||
const fullPlaylist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlist._id)
|
||||
);
|
||||
const snapshot = normalizeStoredStalkerAccountInfo(
|
||||
fullPlaylist?.stalkerAccountInfo
|
||||
);
|
||||
facts =
|
||||
snapshot?.expireDate !== undefined
|
||||
? {
|
||||
expiresAtSeconds: snapshot.expireDate,
|
||||
reportedExpired: false,
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
// Don't cache failures — the next refresh should retry the read.
|
||||
return null;
|
||||
}
|
||||
|
||||
this.stalkerFactsCache.set(playlist._id, { fingerprint, facts });
|
||||
return facts;
|
||||
}
|
||||
|
||||
private applyIfCurrent(
|
||||
round: number,
|
||||
update: (
|
||||
map: ReadonlyMap<string, SourceExpiryFacts>
|
||||
) => ReadonlyMap<string, SourceExpiryFacts>
|
||||
): void {
|
||||
if (round !== this.refreshRound) {
|
||||
return;
|
||||
}
|
||||
this.ngZone.run(() => {
|
||||
this.factsState.update(update);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { PlaylistsService, PortalStatusService } from '@iptvnator/services';
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { DashboardSourceExpiryService } from './dashboard-source-expiry.service';
|
||||
import {
|
||||
SOURCE_EXPIRY_WARNING_DAYS,
|
||||
resolveSourceExpiryBadge,
|
||||
} from './dashboard-source-expiry.util';
|
||||
|
||||
const DAY_SECONDS = 86_400;
|
||||
|
||||
describe('resolveSourceExpiryBadge', () => {
|
||||
const nowMs = Date.UTC(2026, 7, 1, 12, 0, 0);
|
||||
const nowSeconds = nowMs / 1000;
|
||||
|
||||
it('returns null without facts', () => {
|
||||
expect(resolveSourceExpiryBadge(null, nowMs)).toBeNull();
|
||||
expect(resolveSourceExpiryBadge(undefined, nowMs)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns expired when the portal reports an expired account without a timestamp', () => {
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{ expiresAtSeconds: null, reportedExpired: true },
|
||||
nowMs
|
||||
)
|
||||
).toEqual({ kind: 'expired' });
|
||||
});
|
||||
|
||||
it('returns expired for timestamps in the past', () => {
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{ expiresAtSeconds: nowSeconds - 60, reportedExpired: false },
|
||||
nowMs
|
||||
)
|
||||
).toEqual({ kind: 'expired' });
|
||||
});
|
||||
|
||||
it('warns with ceil-ed days inside the warning window', () => {
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{
|
||||
expiresAtSeconds: nowSeconds + 2.5 * DAY_SECONDS,
|
||||
reportedExpired: false,
|
||||
},
|
||||
nowMs
|
||||
)
|
||||
).toEqual({ kind: 'expiring', daysLeft: 3 });
|
||||
});
|
||||
|
||||
it('includes the boundary day but not one hour past it', () => {
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{
|
||||
expiresAtSeconds:
|
||||
nowSeconds + SOURCE_EXPIRY_WARNING_DAYS * DAY_SECONDS,
|
||||
reportedExpired: false,
|
||||
},
|
||||
nowMs
|
||||
)
|
||||
).toEqual({ kind: 'expiring', daysLeft: SOURCE_EXPIRY_WARNING_DAYS });
|
||||
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{
|
||||
expiresAtSeconds:
|
||||
nowSeconds +
|
||||
SOURCE_EXPIRY_WARNING_DAYS * DAY_SECONDS +
|
||||
3600,
|
||||
reportedExpired: false,
|
||||
},
|
||||
nowMs
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('treats unknown and sentinel expirations as no badge', () => {
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{ expiresAtSeconds: null, reportedExpired: false },
|
||||
nowMs
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveSourceExpiryBadge(
|
||||
{ expiresAtSeconds: 0, reportedExpired: false },
|
||||
nowMs
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardSourceExpiryService', () => {
|
||||
let service: DashboardSourceExpiryService;
|
||||
let portalStatusService: { checkPortalStatusDetails: jest.Mock };
|
||||
let playlistsService: { getPlaylistById: jest.Mock };
|
||||
|
||||
const xtreamPlaylist = {
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream',
|
||||
serverUrl: 'https://provider.example.test',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
} as PlaylistMeta;
|
||||
|
||||
const stalkerPlaylist = {
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker',
|
||||
portalUrl: 'https://stalker.example.test/portal.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
updateDate: 1_750_000_000_000,
|
||||
} as PlaylistMeta;
|
||||
|
||||
const m3uPlaylist = {
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U',
|
||||
url: 'https://example.com/playlist.m3u',
|
||||
} as PlaylistMeta;
|
||||
|
||||
beforeEach(() => {
|
||||
portalStatusService = { checkPortalStatusDetails: jest.fn() };
|
||||
playlistsService = { getPlaylistById: jest.fn() };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardSourceExpiryService,
|
||||
{
|
||||
provide: PortalStatusService,
|
||||
useValue: portalStatusService,
|
||||
},
|
||||
{ provide: PlaylistsService, useValue: playlistsService },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(DashboardSourceExpiryService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('collects Xtream facts from the shared status details', async () => {
|
||||
const expiresAtSeconds = Math.floor(Date.now() / 1000) + 3 * DAY_SECONDS;
|
||||
portalStatusService.checkPortalStatusDetails.mockResolvedValue({
|
||||
status: 'active',
|
||||
expiresAtSeconds,
|
||||
});
|
||||
|
||||
await service.refresh([xtreamPlaylist]);
|
||||
|
||||
expect(service.facts().get('xtream-1')).toEqual({
|
||||
expiresAtSeconds,
|
||||
reportedExpired: false,
|
||||
});
|
||||
expect(
|
||||
portalStatusService.checkPortalStatusDetails
|
||||
).toHaveBeenCalledWith(
|
||||
'https://provider.example.test',
|
||||
'demo',
|
||||
'secret'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks Xtream sources the portal reports as expired', async () => {
|
||||
portalStatusService.checkPortalStatusDetails.mockResolvedValue({
|
||||
status: 'expired',
|
||||
expiresAtSeconds: null,
|
||||
});
|
||||
|
||||
await service.refresh([xtreamPlaylist]);
|
||||
|
||||
expect(service.facts().get('xtream-1')).toEqual({
|
||||
expiresAtSeconds: null,
|
||||
reportedExpired: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('records no facts for unreachable portals or failed checks', async () => {
|
||||
portalStatusService.checkPortalStatusDetails.mockResolvedValueOnce({
|
||||
status: 'unavailable',
|
||||
expiresAtSeconds: null,
|
||||
});
|
||||
await service.refresh([xtreamPlaylist]);
|
||||
expect(service.facts().has('xtream-1')).toBe(false);
|
||||
|
||||
portalStatusService.checkPortalStatusDetails.mockRejectedValueOnce(
|
||||
new Error('offline')
|
||||
);
|
||||
await service.refresh([xtreamPlaylist]);
|
||||
expect(service.facts().has('xtream-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads Stalker expirations from the stored snapshot and memoizes the read', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
// Stored verbatim at import — a date string despite the
|
||||
// declared number type; the normalizer must parse it.
|
||||
stalkerAccountInfo: { expireDate: '2027-10-01' },
|
||||
})
|
||||
);
|
||||
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
|
||||
const facts = service.facts().get('stalker-1');
|
||||
expect(facts?.reportedExpired).toBe(false);
|
||||
expect(facts?.expiresAtSeconds).toBe(
|
||||
Math.round(new Date(2027, 9, 1).getTime() / 1000)
|
||||
);
|
||||
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-reads the Stalker snapshot after a playlist refresh changes the fingerprint', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
stalkerAccountInfo: { expireDate: '2027-10-01' },
|
||||
})
|
||||
);
|
||||
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
await service.refresh([
|
||||
{ ...stalkerPlaylist, updateDate: 1_760_000_000_000 },
|
||||
]);
|
||||
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('records no facts when the snapshot is absent or unlimited', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
stalkerAccountInfo: { expireDate: '-1' },
|
||||
})
|
||||
);
|
||||
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
|
||||
expect(service.facts().has('stalker-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not cache failed Stalker reads', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValueOnce(
|
||||
throwError(() => new Error('db down'))
|
||||
);
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
expect(service.facts().has('stalker-1')).toBe(false);
|
||||
|
||||
playlistsService.getPlaylistById.mockReturnValueOnce(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
stalkerAccountInfo: { expireDate: '2027-10-01' },
|
||||
})
|
||||
);
|
||||
await service.refresh([stalkerPlaylist]);
|
||||
expect(service.facts().has('stalker-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores M3U playlists and prunes removed sources', async () => {
|
||||
portalStatusService.checkPortalStatusDetails.mockResolvedValue({
|
||||
status: 'active',
|
||||
expiresAtSeconds: Math.floor(Date.now() / 1000) + DAY_SECONDS,
|
||||
});
|
||||
|
||||
await service.refresh([xtreamPlaylist, m3uPlaylist]);
|
||||
expect(service.facts().has('xtream-1')).toBe(true);
|
||||
expect(service.facts().has('m3u-1')).toBe(false);
|
||||
|
||||
await service.refresh([m3uPlaylist]);
|
||||
expect(service.facts().has('xtream-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Warn this many days before a portal subscription lapses. */
|
||||
export const SOURCE_EXPIRY_WARNING_DAYS = 7;
|
||||
|
||||
/**
|
||||
* How often badge consumers should re-evaluate {@link resolveSourceExpiryBadge}
|
||||
* against the wall clock. The badge state only moves at day granularity, but a
|
||||
* dashboard left open must still cross day-countdown and expiration
|
||||
* boundaries without a remount; a minute tick is imperceptibly cheap.
|
||||
*/
|
||||
export const SOURCE_EXPIRY_TICK_MS = 60_000;
|
||||
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
|
||||
/**
|
||||
* What a source's account facts say about its subscription lifetime.
|
||||
* `reportedExpired` covers portals that answer with an expired account
|
||||
* status without a usable timestamp; `expiresAtSeconds` is unix seconds
|
||||
* when the portal reports one.
|
||||
*/
|
||||
export interface SourceExpiryFacts {
|
||||
expiresAtSeconds: number | null;
|
||||
reportedExpired: boolean;
|
||||
}
|
||||
|
||||
export type SourceExpiryBadge =
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'expiring'; daysLeft: number };
|
||||
|
||||
/**
|
||||
* Decides whether a source card should carry an expiry badge. Returns null
|
||||
* for unknown expirations and for subscriptions further out than
|
||||
* `warningDays` — the badge is a warning, not a countdown.
|
||||
*/
|
||||
export function resolveSourceExpiryBadge(
|
||||
facts: SourceExpiryFacts | null | undefined,
|
||||
nowMs: number,
|
||||
warningDays: number = SOURCE_EXPIRY_WARNING_DAYS
|
||||
): SourceExpiryBadge | null {
|
||||
if (!facts) {
|
||||
return null;
|
||||
}
|
||||
if (facts.reportedExpired) {
|
||||
return { kind: 'expired' };
|
||||
}
|
||||
|
||||
const expiresAt = facts.expiresAtSeconds;
|
||||
if (expiresAt === null || expiresAt <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secondsLeft = expiresAt - nowMs / 1000;
|
||||
if (secondsLeft <= 0) {
|
||||
return { kind: 'expired' };
|
||||
}
|
||||
|
||||
const daysLeft = Math.ceil(secondsLeft / SECONDS_PER_DAY);
|
||||
return daysLeft <= warningDays ? { kind: 'expiring', daysLeft } : null;
|
||||
}
|
||||
@@ -204,6 +204,20 @@
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
@if (card.expiryBadge; as expiry) {
|
||||
<span
|
||||
class="rail__card-expiry"
|
||||
[class.rail__card-expiry--expired]="
|
||||
expiry.kind === 'expired'
|
||||
"
|
||||
[attr.data-test-id]="
|
||||
testId()
|
||||
? testId() + '-card-expiry'
|
||||
: null
|
||||
"
|
||||
>{{ expiry.label }}</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -375,6 +375,35 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Subscription-expiry chip on portal source cards. Mirrors the episode
|
||||
// chip's geometry; the theme-aware token pairs (m3-theme.scss) keep the
|
||||
// small bold text at >= 4.5:1 contrast in both themes (amber = expiring
|
||||
// soon, error tone = already expired).
|
||||
.rail__card-expiry {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(
|
||||
--app-expiry-warn-surface,
|
||||
color-mix(in srgb, #d97706 16%, transparent)
|
||||
);
|
||||
color: var(--app-expiry-warn-text, #8a5300);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rail__card-expiry--expired {
|
||||
background: var(
|
||||
--app-expiry-expired-surface,
|
||||
color-mix(in srgb, #f44336 14%, transparent)
|
||||
);
|
||||
color: var(--app-expiry-expired-text, #b3261e);
|
||||
}
|
||||
|
||||
.rail__card-subtitle {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
DashboardRailCard,
|
||||
@@ -69,4 +71,67 @@ describe('DashboardRailComponent', () => {
|
||||
'US | CNN'
|
||||
);
|
||||
});
|
||||
|
||||
describe('expiry badge rendering', () => {
|
||||
beforeEach(() => {
|
||||
// jsdom has no ResizeObserver; the component observes its track
|
||||
// element after view init.
|
||||
(
|
||||
globalThis as unknown as { ResizeObserver: unknown }
|
||||
).ResizeObserver = class {
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
};
|
||||
});
|
||||
|
||||
const renderCards = async (items: DashboardRailCard[]) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DashboardRailComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { stripCountryPrefix: signal(false) },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(DashboardRailComponent);
|
||||
fixture.componentRef.setInput('label', 'Sources');
|
||||
fixture.componentRef.setInput('items', items);
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
};
|
||||
|
||||
it('renders the chip only for cards with a badge and tones expired ones', async () => {
|
||||
const element = await renderCards([
|
||||
card({
|
||||
id: 'expiring',
|
||||
subtitle: 'Xtream',
|
||||
expiryBadge: {
|
||||
kind: 'expiring',
|
||||
label: 'Expires in 3 d',
|
||||
},
|
||||
}),
|
||||
card({
|
||||
id: 'expired',
|
||||
subtitle: 'Stalker',
|
||||
expiryBadge: { kind: 'expired', label: 'Expired' },
|
||||
}),
|
||||
card({ id: 'plain', subtitle: 'M3U' }),
|
||||
]);
|
||||
|
||||
const chips = element.querySelectorAll('.rail__card-expiry');
|
||||
expect(chips).toHaveLength(2);
|
||||
expect(chips[0].textContent?.trim()).toBe('Expires in 3 d');
|
||||
expect(
|
||||
chips[0].classList.contains('rail__card-expiry--expired')
|
||||
).toBe(false);
|
||||
expect(chips[1].textContent?.trim()).toBe('Expired');
|
||||
expect(
|
||||
chips[1].classList.contains('rail__card-expiry--expired')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,13 @@ export interface DashboardRailCard {
|
||||
* user can see which episode they were on without opening the show.
|
||||
*/
|
||||
episodeBadge?: string | null;
|
||||
|
||||
/**
|
||||
* Subscription-expiry warning for portal source cards: a quiet amber
|
||||
* chip when the account expires soon, an error-toned one once it has.
|
||||
* The chip is passive — account details stay behind ⋮ → Account info.
|
||||
*/
|
||||
expiryBadge?: { kind: 'expiring' | 'expired'; label: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+88
-24
@@ -8,10 +8,12 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { interval, of, startWith, switchMap } from 'rxjs';
|
||||
import { interval, map, 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';
|
||||
@@ -41,9 +43,12 @@ import {
|
||||
DashboardDataService,
|
||||
DashboardFavoriteItem,
|
||||
DashboardRecentlyAddedItem,
|
||||
DashboardSourceExpiryService,
|
||||
DashboardTrendingItem,
|
||||
DashboardTrendingService,
|
||||
GlobalRecentItem,
|
||||
resolveSourceExpiryBadge,
|
||||
SOURCE_EXPIRY_TICK_MS,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import type { DashboardHeroTmdbExtras } from './dashboard-hero-tmdb.service';
|
||||
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
|
||||
@@ -73,7 +78,6 @@ import {
|
||||
buildDashboardCollectionViewState,
|
||||
buildDashboardRailSeeAllState,
|
||||
buildDashboardSourceActions,
|
||||
isXtreamAccountPlaylist,
|
||||
liveRailTitleKeyForSource,
|
||||
RAIL_ITEM_LIMIT,
|
||||
shouldShowLiveFavoritesSkeleton,
|
||||
@@ -120,6 +124,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly heroTmdb = inject(DashboardHeroTmdbService);
|
||||
private readonly sourceExpiry = inject(DashboardSourceExpiryService);
|
||||
readonly trendingService = inject(DashboardTrendingService);
|
||||
|
||||
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
|
||||
@@ -338,8 +343,23 @@ export class WorkspaceDashboardRailsComponent {
|
||||
.map((item) => this.toTrendingCard(item));
|
||||
});
|
||||
|
||||
readonly sourceCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data.recentPlaylists().map((playlist) => ({
|
||||
// Minute heartbeat for the expiry badges: resolveSourceExpiryBadge reads
|
||||
// the wall clock, so without a reactive tick a dashboard left open would
|
||||
// never cross a day-countdown or expiration boundary. interval() emits
|
||||
// 0 first — shifted by one so it differs from initialValue, otherwise
|
||||
// the signal's equality check would swallow the first tick.
|
||||
private readonly sourceExpiryTick = toSignal(
|
||||
interval(SOURCE_EXPIRY_TICK_MS).pipe(map((tick) => tick + 1)),
|
||||
{ initialValue: 0 }
|
||||
);
|
||||
|
||||
readonly sourceCards = computed<DashboardRailCard[]>(() => {
|
||||
// Explicit language dependency for the translate.instant() calls
|
||||
// below (title fallback, expiry-badge labels). getPlaylistProvider
|
||||
// also reads the data service's language tick, but the labels must
|
||||
// not rely on that indirection surviving refactors.
|
||||
this.languageTick();
|
||||
return this.data.recentPlaylists().map((playlist) => ({
|
||||
id: playlist._id,
|
||||
title:
|
||||
playlist.title ||
|
||||
@@ -356,8 +376,9 @@ export class WorkspaceDashboardRailsComponent {
|
||||
playlist,
|
||||
this.playlistRefreshAction.canRefresh(playlist)
|
||||
),
|
||||
}))
|
||||
);
|
||||
expiryBadge: this.buildSourceExpiryBadge(playlist._id),
|
||||
}));
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Re-entering the dashboard should pick up any DB-backed recent/favorite
|
||||
@@ -409,6 +430,19 @@ export class WorkspaceDashboardRailsComponent {
|
||||
});
|
||||
});
|
||||
|
||||
// Subscription-expiry badges for the source cards. Xtream rides the
|
||||
// shared PortalStatusService cache; Stalker reads the import-time
|
||||
// snapshot (memoized per playlist), so this stays cheap on re-entry.
|
||||
// Gated on the sources rail being enabled — with the rail hidden no
|
||||
// badge can render, so the status checks would be pure waste.
|
||||
effect(() => {
|
||||
if (!this.dashboardRails().recentSources) {
|
||||
return;
|
||||
}
|
||||
const playlists = this.data.recentPlaylists();
|
||||
untracked(() => void this.sourceExpiry.refresh(playlists));
|
||||
});
|
||||
|
||||
// Trending rail: needs the TMDB opt-in and the Electron DB worker.
|
||||
// Deferred until the dashboard's own recent/favorites data is in so
|
||||
// the batched title match never competes for the worker at startup.
|
||||
@@ -450,7 +484,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);
|
||||
@@ -596,32 +630,62 @@ export class WorkspaceDashboardRailsComponent {
|
||||
};
|
||||
}
|
||||
|
||||
private buildSourceExpiryBadge(
|
||||
playlistId: string
|
||||
): DashboardRailCard['expiryBadge'] {
|
||||
// Reactive read: ties the wall-clock evaluation below to the minute
|
||||
// tick (this method only runs inside the sourceCards computed).
|
||||
this.sourceExpiryTick();
|
||||
const badge = resolveSourceExpiryBadge(
|
||||
this.sourceExpiry.facts().get(playlistId),
|
||||
Date.now()
|
||||
);
|
||||
if (!badge) {
|
||||
return null;
|
||||
}
|
||||
return badge.kind === 'expired'
|
||||
? {
|
||||
kind: 'expired',
|
||||
label: this.t('WORKSPACE.DASHBOARD.SOURCE_EXPIRED'),
|
||||
}
|
||||
: {
|
||||
kind: 'expiring',
|
||||
label: this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SOURCE_EXPIRES_IN_DAYS',
|
||||
{ days: badge.daysLeft }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private typeIcon(type: 'live' | 'movie' | 'series'): string {
|
||||
if (type === 'live') return 'live_tv';
|
||||
if (type === 'movie') return 'movie';
|
||||
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 {
|
||||
|
||||
+7
@@ -2,6 +2,7 @@ import { Component, computed, inject } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
/**
|
||||
* Context panel rendered in the workspace-shell aside for
|
||||
@@ -52,6 +53,11 @@ import { WorkspaceContextCategoryViewComponent } from './components/workspace-co
|
||||
})
|
||||
export class WorkspaceCollectionContextPanelComponent {
|
||||
readonly ctx = inject(PortalCollectionContextService);
|
||||
// Root-provided; optional keeps standalone unit tests light. Closes the phone
|
||||
// drawer after a selection, which never navigates on this panel.
|
||||
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
readonly selectedCategory = computed(() => {
|
||||
const categories = this.ctx.categories();
|
||||
@@ -70,5 +76,6 @@ export class WorkspaceCollectionContextPanelComponent {
|
||||
}): void {
|
||||
const id = String(item.category_id ?? item.id ?? 'all');
|
||||
this.ctx.setCategoryId(id);
|
||||
this.contextDrawer?.close();
|
||||
}
|
||||
}
|
||||
+7
@@ -7,6 +7,7 @@ import { of } from 'rxjs';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { WORKSPACE_CATEGORY_SORT_STORAGE_KEY } from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
XtreamContentLoadState,
|
||||
XtreamStore,
|
||||
@@ -171,6 +172,12 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
provide: MatDialog,
|
||||
useValue: dialog,
|
||||
},
|
||||
{
|
||||
// Root-provided in production; stubbed because the spec's
|
||||
// Router mock has no `events` stream for the real service.
|
||||
provide: WorkspaceShellContextDrawerService,
|
||||
useValue: { close: jest.fn(), isOpen: () => false },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
+12
@@ -28,6 +28,7 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component';
|
||||
import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component';
|
||||
import { hasActiveLiveCategoryRoute } from './workspace-context-panel-route.utils';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists';
|
||||
|
||||
@@ -69,6 +70,13 @@ export class WorkspaceContextPanelComponent {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly translate = inject(TranslateService);
|
||||
// Root-provided; optional keeps standalone unit tests light. Only relevant
|
||||
// when the panel renders as the phone drawer. Some selections here (e.g.
|
||||
// Stalker ITV/radio) update the store without navigating, so the drawer's
|
||||
// NavigationEnd auto-close never fires for them.
|
||||
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
readonly context = input.required<WorkspaceContextRoute>();
|
||||
readonly section = input.required<string>();
|
||||
@@ -370,6 +378,7 @@ export class WorkspaceContextPanelComponent {
|
||||
return;
|
||||
}
|
||||
const categoryId = numericCategoryId;
|
||||
this.contextDrawer?.close();
|
||||
|
||||
if (section === 'live') {
|
||||
this.xtreamStore.setSelectedCategory(categoryId);
|
||||
@@ -410,6 +419,7 @@ export class WorkspaceContextPanelComponent {
|
||||
const section = this.section();
|
||||
const categoryId = String(item.category_id ?? '*');
|
||||
|
||||
this.contextDrawer?.close();
|
||||
this.stalkerStore.setSelectedCategory(categoryId);
|
||||
this.stalkerStore.setPage(0);
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
@@ -451,6 +461,8 @@ export class WorkspaceContextPanelComponent {
|
||||
|
||||
private getXtreamImportPhaseLabelKey(phase: string | null): string {
|
||||
switch (phase) {
|
||||
case 'loading-cached':
|
||||
return 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING_CACHED';
|
||||
case 'preparing-content':
|
||||
return 'WORKSPACE.SHELL.XTREAM_IMPORT_PREPARING';
|
||||
case 'loading-categories':
|
||||
|
||||
+27
-1
@@ -26,10 +26,17 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// `height: 100%` here does not resolve against a stretched flex item, so the
|
||||
// list kept its full content height and painted over the footer whenever the
|
||||
// panel was shorter than its sections (a phone, or a short desktop window).
|
||||
.settings-sections-list {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-panel-footer {
|
||||
@@ -42,3 +49,22 @@
|
||||
.settings-back-button:hover:not(.active):not(.selected) {
|
||||
background: var(--app-selection-surface);
|
||||
}
|
||||
|
||||
// On a landscape phone the stacked panel gets ~108-148px, and this panel's
|
||||
// fixed chrome (title + footer) alone is about that tall — the section list
|
||||
// was collapsing to nothing behind an overlapping footer. The rail already
|
||||
// labels the page, so the caption is what gives way, and the footer sheds
|
||||
// the padding it carries for taller screens.
|
||||
@media (max-width: 640px) and (max-height: 599px) {
|
||||
.panel-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-panel-body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.settings-panel-footer {
|
||||
padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -2,7 +2,10 @@ import { Location } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
WorkspaceShellContextDrawerService,
|
||||
SettingsContextService
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-settings-context-panel',
|
||||
@@ -18,7 +21,7 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
class="nav-item settings-section-item"
|
||||
[class.active]="ctx.activeSection() === section.id"
|
||||
[attr.data-test-id]="'settings-section-' + section.id"
|
||||
(click)="ctx.navigateToSection(section.id)"
|
||||
(click)="onSectionClicked(section.id)"
|
||||
>
|
||||
<mat-icon>{{ section.icon }}</mat-icon>
|
||||
<span>{{ section.label | translate }}</span>
|
||||
@@ -41,6 +44,17 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
export class WorkspaceSettingsContextPanelComponent {
|
||||
readonly ctx = inject(SettingsContextService);
|
||||
private readonly location = inject(Location);
|
||||
// Root-provided; optional keeps standalone unit tests light. Section clicks
|
||||
// scroll the settings page without navigating, so the phone drawer's
|
||||
// NavigationEnd auto-close never fires for them.
|
||||
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
onSectionClicked(sectionId: string) {
|
||||
this.ctx.navigateToSection(sectionId);
|
||||
this.contextDrawer?.close();
|
||||
}
|
||||
|
||||
onBack() {
|
||||
this.location.back();
|
||||
|
||||
+25
@@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { Subject } from 'rxjs';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { WorkspaceKeyboardShortcutsService } from './workspace-keyboard-shortcuts.service';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
describe('WorkspaceKeyboardShortcutsService', () => {
|
||||
let afterClosed$: Subject<void>;
|
||||
@@ -52,6 +53,30 @@ describe('WorkspaceKeyboardShortcutsService', () => {
|
||||
expect(dialog.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not open the dialog over the modal phone context drawer', () => {
|
||||
TestBed.resetTestingModule();
|
||||
const gatedDialog = { open: jest.fn() };
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WorkspaceKeyboardShortcutsService,
|
||||
{ provide: MatDialog, useValue: gatedDialog },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { isElectron: true },
|
||||
},
|
||||
{
|
||||
provide: WorkspaceShellContextDrawerService,
|
||||
useValue: { isOpen: () => true },
|
||||
},
|
||||
],
|
||||
});
|
||||
TestBed.inject(WorkspaceKeyboardShortcutsService);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '?' }));
|
||||
|
||||
expect(gatedDialog.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not open while typing in an input', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
+11
-1
@@ -11,12 +11,18 @@ import {
|
||||
WorkspaceKeyboardShortcutsDialogComponent,
|
||||
WorkspaceKeyboardShortcutsDialogData,
|
||||
} from './workspace-keyboard-shortcuts-dialog.component';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceKeyboardShortcutsService {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
// Root-provided; optional keeps standalone unit tests light. The help key must not open a dialog over the modal phone
|
||||
// context drawer.
|
||||
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
|
||||
optional: true,
|
||||
});
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void =>
|
||||
this.handleKeydown(event);
|
||||
|
||||
@@ -72,7 +78,11 @@ export class WorkspaceKeyboardShortcutsService {
|
||||
}
|
||||
|
||||
private handleKeydown(event: KeyboardEvent): void {
|
||||
if (isTypingInInput(event) || !isKeyboardShortcutHelpTrigger(event)) {
|
||||
if (
|
||||
isTypingInInput(event) ||
|
||||
!isKeyboardShortcutHelpTrigger(event) ||
|
||||
this.contextDrawer?.isOpen()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+24
@@ -1,5 +1,23 @@
|
||||
<!-- Phone-only (CSS-hidden above 640px): touch screen-reader users have no
|
||||
hardware Escape and cannot reach the inert header toggle or the
|
||||
aria-hidden backdrop, so the trapped drawer itself must always carry a
|
||||
dismiss control — even when a category list is loading or empty. -->
|
||||
<div class="context-drawer-header">
|
||||
<button
|
||||
type="button"
|
||||
class="context-drawer-close"
|
||||
data-test-id="context-drawer-close"
|
||||
(click)="closeDrawer()"
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (variant() === 'sources' && hasPlaylists()) {
|
||||
<aside
|
||||
tabindex="-1"
|
||||
cdkFocusInitial
|
||||
class="context-panel context-panel--sources"
|
||||
appResizable
|
||||
[minWidth]="220"
|
||||
@@ -13,6 +31,8 @@
|
||||
@if (context(); as context) {
|
||||
@if (section(); as section) {
|
||||
<aside
|
||||
tabindex="-1"
|
||||
cdkFocusInitial
|
||||
class="context-panel context-panel--route"
|
||||
[class.context-panel--collapsed]="isContextPanelCollapsed()"
|
||||
appResizable
|
||||
@@ -30,6 +50,8 @@
|
||||
}
|
||||
} @else if (variant() === 'settings') {
|
||||
<aside
|
||||
tabindex="-1"
|
||||
cdkFocusInitial
|
||||
class="context-panel context-panel--settings"
|
||||
appResizable
|
||||
[minWidth]="200"
|
||||
@@ -41,6 +63,8 @@
|
||||
</aside>
|
||||
} @else if (variant() === 'collection') {
|
||||
<aside
|
||||
tabindex="-1"
|
||||
cdkFocusInitial
|
||||
class="context-panel context-panel--route"
|
||||
appResizable
|
||||
[minWidth]="200"
|
||||
|
||||
+116
@@ -4,6 +4,12 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// Dismiss affordance for the phone drawer; the desktop sidebar is not
|
||||
// dismissable, so the whole row only exists below the breakpoint.
|
||||
.context-drawer-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-panel {
|
||||
border-right: 1px solid var(--mat-sys-outline-variant);
|
||||
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
|
||||
@@ -57,3 +63,113 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.context-panel--settings app-workspace-settings-context-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Below this width the sidebar leaves the layout entirely and becomes an
|
||||
// off-canvas drawer: position: fixed takes the host out of the shell grid, so
|
||||
// the route content owns the full pane, and the workspace shell slides the
|
||||
// drawer in by toggling the `drawer-open` class on this host. Hidden state
|
||||
// uses visibility (delayed until the slide-out finishes) so the off-screen
|
||||
// panel can't be tabbed or scrolled into.
|
||||
@media (max-width: 640px) {
|
||||
:host {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
// Narrower than the viewport so the backdrop stays tappable — it is
|
||||
// one of the two ways to close the drawer.
|
||||
width: min(320px, 85vw);
|
||||
// Above every routed/app surface (settings action bar 100, EPG and
|
||||
// update panels 900/901 — inert removes them from interaction but
|
||||
// not from paint order), below the CDK overlay container (1000):
|
||||
// dialogs opened from inside the drawer, e.g. Manage categories,
|
||||
// must stack on top of it.
|
||||
z-index: 951;
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
visibility 0s linear 200ms;
|
||||
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
:host(.drawer-open) {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
|
||||
}
|
||||
|
||||
.context-drawer-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 8px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-drawer-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--app-on-surface, var(--mat-sys-on-surface));
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--app-card-hover-bg, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
}
|
||||
|
||||
// `!important` is required because ResizableDirective writes the
|
||||
// persisted desktop width as an inline style. Dragging is meaningless
|
||||
// inside the drawer, so the handle goes away with it.
|
||||
.context-panel {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
// There is no global border-box reset, so the panel's own padding
|
||||
// would push a 100%-wide panel past the drawer.
|
||||
box-sizing: border-box;
|
||||
// The close-button row above takes its share; the panel fills the
|
||||
// rest of the drawer column.
|
||||
height: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// The live layout's collapse toggle folds the panel away beside the
|
||||
// content on desktop. Inside the drawer that state is meaningless — the
|
||||
// user explicitly opened it — and honouring it would show an empty strip.
|
||||
.context-panel--collapsed {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
padding: 12px 10px 10px 12px;
|
||||
border-right-color: var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.context-panel :is(.resize-handle) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-panel--route,
|
||||
.context-panel--sources {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user