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:
4gray committed 2026-08-02 18:06:37 +02:00
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();
@@ -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;
}
}
@@ -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 () => {
@@ -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,
@@ -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');
});
});
@@ -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
View File
@@ -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';
@@ -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>
@@ -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;
}
}
@@ -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();
});
});
@@ -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() : '-';
}
}
@@ -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;
}
}
}
@@ -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 {
@@ -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();
@@ -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
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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: {
+48 -11
View File
@@ -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 };
}
/**
+6
View File
@@ -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;
}
}
+1
View File
@@ -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);
@@ -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);
});
});
@@ -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();
}
});
}
+533
View File
@@ -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;
}
}
+3
View File
@@ -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) {
+63
View File
@@ -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 {
@@ -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',
]);
});
});
@@ -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 {
@@ -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,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();
@@ -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':
@@ -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));
}
}
@@ -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();
@@ -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,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;
}
@@ -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"
@@ -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