feat(playback): identify content recovery sessions

This commit is contained in:
4gray committed 2026-08-04 21:27:33 +02:00
1 parent 4c16f9b7a0
commit 5cc10ad7e2
58 files changed
+2895 -460

No files matched your search

@@ -0,0 +1,9 @@
---
type: fix
area: playback
issues: [1159]
---
Playback recovery no longer crosses into a different selected channel, movie,
or exact Stalker episode. Switching content while a stream resolves no longer
restores stale playback or shows an error from the previous selection.
@@ -38,6 +38,37 @@ Collection/search VOD surfaces that expose embedded playback must host
`PlayerService.openPlayer(...)` or `PlayerService.openResolvedPlayback(...)` to
create embedded UI.
## Logical Playback Identity
Every inline playback host owns a required, URL-independent
`playbackSessionKey`. Live hosts derive it from the playlist/source and the
current channel identity; an M3U session uses `Channel.id` rather than a mutable
stream or catch-up URL. VOD and series hosts use the route or catalog
content identity, with series episode coordinates. Stalker episode identity
also includes the series mode, normalized parent, exact season key, and the
original provider command or episode ID; synthesized episode hashes are not
identity. Selection and pending-resolution guards use that exact provider
episode identity, so colliding tracking hashes cannot select a sibling episode.
Shared wrappers (`VodDetailsComponent` and `PortalInlinePlayerComponent`) pass
the key unchanged to `WebPlayerViewComponent`.
Hosts invalidate both committed playback and pending resolution when the
canonical owner changes (playlist/source, content, and mode where applicable).
Refreshing data for the same canonical owner preserves the mounted player.
Collection UIDs remain a separate persistence concern; legacy M3U collection
UIDs continue to use stream URLs so saved favorites ordering remains compatible.
Temporary portal URLs, catch-up URLs, headers, DRM data, and alternative source
payloads are transport details and must not change this logical identity. A
content or episode change must produce a different key. The serialized key is
created with `createPlaybackSessionKey()` from `@iptvnator/playback/util` so
delimiter-bearing provider IDs remain unambiguous.
Inline hosts capture this identity before asynchronous playback resolution. A
completion may mount only while the same owner is current; stale completions
and embedded starts without a complete canonical identity are ignored without
replacing an already committed session.
## Embedded MPV Harness
The repository now contains a first-pass native embedded MPV harness for Electron:
@@ -79,6 +79,7 @@
/>
} @else {
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
[title]="inlinePlayerTitle()"
[playback]="embeddedPlayback()"
@@ -112,7 +113,9 @@
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
@@ -138,7 +141,9 @@
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
@@ -41,12 +41,14 @@ import {
import {
Channel,
EpgProgram,
ResolvedPortalPlayback,
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { Overlay } from '@angular/cdk/overlay';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
jest.unstable_mockModule('video.js', () => ({
@@ -113,6 +115,7 @@ class StubAudioPlayerComponent {
template: '',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
@@ -311,6 +314,7 @@ describe('VideoPlayerComponent', () => {
} as typeof window.electron;
syncStoreState(null);
playlistId.set('playlist-1');
localStorage.removeItem('m3u-sidebar-width');
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
player.set(VideoPlayer.VideoJs);
@@ -535,7 +539,9 @@ describe('VideoPlayerComponent', () => {
fixture.nativeElement.querySelector('app-web-player-view')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
expect(headerContext.action()).toBeNull();
});
@@ -564,12 +570,19 @@ describe('VideoPlayerComponent', () => {
},
} as Channel);
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
title: 'Sample TV',
isLive: true,
headers: {
'user-agent': 'IPTVnator Test',
Referer: 'https://referrer.example.com',
Origin: 'https://origin.example.com',
},
};
component.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
title: 'Archive Sample',
},
playback,
diagnostic: {
code: 'unsupported-codec',
source: 'hls',
@@ -583,13 +596,13 @@ describe('VideoPlayerComponent', () => {
expect(dataServiceMock.sendIpcEvent).toHaveBeenCalledWith(
'OPEN_MPV_PLAYER',
expect.objectContaining({
{
url: 'https://archive.example.com/live.m3u8?utc=1',
title: 'Sample TV',
'user-agent': 'IPTVnator Test',
referer: 'https://referrer.example.com',
origin: 'https://origin.example.com',
})
}
);
});
@@ -640,12 +653,36 @@ describe('VideoPlayerComponent', () => {
By.directive(StubWebPlayerViewComponent)
);
expect(playerView).not.toBeNull();
const stub =
playerView.componentInstance as StubWebPlayerViewComponent;
const stub = playerView.componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled();
});
it('owns a collision-safe live session key that ignores resolved URL changes', () => {
playlistId.set('playlist|one');
syncStoreState({ ...sampleChannel, id: 'channel|one' } as Channel);
activePlaybackUrl.set('https://archive.example/first.m3u8');
fixture.detectChanges();
const playerView = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
const expected = createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist|one',
contentId: 'channel|one',
});
expect(playerView.playbackSessionKey()).toBe(expected);
activePlaybackUrl.set('https://archive.example/second.m3u8');
fixture.detectChanges();
expect(playerView.playbackSessionKey()).toBe(expected);
syncStoreState({ ...sampleChannel, id: 'channel|two' } as Channel);
fixture.detectChanges();
expect(playerView.playbackSessionKey()).not.toBe(expected);
});
it('routes catch-up playback that resolves to a DASH URL inline as well', () => {
syncStoreState(sampleChannel);
activePlaybackUrl.set('http://localhost/archive/replay.mpd');
@@ -657,8 +694,7 @@ describe('VideoPlayerComponent', () => {
By.directive(StubWebPlayerViewComponent)
);
expect(playerView).not.toBeNull();
const stub =
playerView.componentInstance as StubWebPlayerViewComponent;
const stub = playerView.componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
});
@@ -675,9 +711,7 @@ describe('VideoPlayerComponent', () => {
// The external-player guard declines DASH channels, so the inline
// player must stay — otherwise the session has no player at all.
expect(
fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
)
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
).not.toBeNull();
});
@@ -90,6 +90,7 @@ import {
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
import {
DataService,
@@ -166,13 +167,18 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly activePlaybackUrl = this.store.selectSignal(
selectActivePlaybackUrl
);
readonly activeEpgProgram = this.store.selectSignal(
selectActiveEpgProgram
);
readonly activeEpgProgram = this.store.selectSignal(selectActiveEpgProgram);
readonly activeEpgProgramOrNull = computed(
() => this.activeEpgProgram() ?? null
);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
readonly playbackSessionKey = computed(() => {
const sourceId = this.activePlaylistId();
const contentId = this.activeChannel()?.id;
return sourceId && contentId !== undefined
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
: '';
});
readonly channels = this.store.selectSignal(selectChannels);
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
readonly archivePlaybackAvailable = computed(() =>
@@ -235,9 +241,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
private readonly epgChannelLogo = toSignal(
toObservable(this.activeChannel).pipe(
switchMap((channel) => {
const key = channel
? resolveChannelEpgLookupKey(channel)
: '';
const key = channel ? resolveChannelEpgLookupKey(channel) : '';
if (!key) {
return of('');
}
@@ -587,9 +591,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
const currentEpgProgram = epgProgram as
| EpgProgram
| null
| undefined;
EpgProgram | null | undefined;
const currentIndex = channels.findIndex(
(channel) => channel.url === activeChannel.url
);
@@ -383,6 +383,10 @@ describe('UnifiedFavoritesDataService', () => {
'https://example.com/2.m3u8',
'https://example.com/1.m3u8',
]);
expect(items.map((item) => item.uid)).toEqual([
'm3u::m3u-1::https://example.com/2.m3u8',
'm3u::m3u-1::https://example.com/1.m3u8',
]);
expect(items[1].channelId).toBe('channel-1');
expect(items[0].radio).toBe('true');
expect(items[0].m3uChannel).toBe(m3uChannels[1]);
@@ -195,6 +195,7 @@ describe('UnifiedRecentDataService', () => {
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
uid: 'm3u::m3u-1::https://example.com/2.m3u8',
sourceType: 'm3u',
streamUrl: 'https://example.com/2.m3u8',
channelId: 'channel-2',
@@ -0,0 +1,47 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
describe('createUnifiedLivePlaybackSessionKey', () => {
it('keeps an M3U key across transport changes and separates channels', () => {
const first = m3uItem({
uid: 'm3u::pl-1::https://old.example/live.m3u8',
streamUrl: 'https://old.example/live.m3u8',
channelId: 'provider-channel-7',
});
const expected = createPlaybackSessionKey({
kind: 'live',
sourceId: 'pl-1',
contentId: 'provider-channel-7',
});
expect(createUnifiedLivePlaybackSessionKey(first)).toBe(expected);
expect(
createUnifiedLivePlaybackSessionKey({
...first,
uid: 'm3u::pl-1::https://new.example/timeshift.m3u8',
streamUrl: 'https://new.example/timeshift.m3u8',
})
).toBe(expected);
expect(
createUnifiedLivePlaybackSessionKey({
...first,
channelId: 'provider-channel-8',
})
).not.toBe(expected);
});
});
function m3uItem(
overrides: Partial<UnifiedCollectionItem>
): UnifiedCollectionItem {
return {
uid: 'm3u::pl-1::channel',
name: 'Channel',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'pl-1',
playlistName: 'Playlist',
...overrides,
};
}
@@ -0,0 +1,23 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
export function createUnifiedLivePlaybackSessionKey(
item: UnifiedCollectionItem | null
): string {
const sourceId = item?.playlistId.trim() ?? '';
const contentId = item ? getUnifiedLiveContentId(item) : '';
return sourceId && contentId
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
: '';
}
function getUnifiedLiveContentId(item: UnifiedCollectionItem): string {
switch (item.sourceType) {
case 'm3u':
return item.channelId?.trim() || item.m3uChannel?.id?.trim() || '';
case 'xtream':
return item.xtreamId == null ? '' : String(item.xtreamId);
case 'stalker':
return item.stalkerId == null ? '' : String(item.stalkerId).trim();
}
}
@@ -59,6 +59,7 @@
/>
} @else {
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="currentStreamUrl()"
[title]="activeDetail()?.playback?.title ?? ''"
[playback]="inlinePlayback()"
@@ -86,7 +87,9 @@
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archivePlaybackAvailable]="
timelineArchiveAvailable()
"
[archiveDays]="timelineArchiveDays()"
[activeProgram]="activeTimeshiftProgram()"
[isLivePlayback]="!activeTimeshift()"
@@ -113,7 +116,9 @@
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archivePlaybackAvailable]="
timelineArchiveAvailable()
"
[archiveDays]="timelineArchiveDays()"
[activeProgram]="activeTimeshiftProgram()"
[isLivePlayback]="!activeTimeshift()"
@@ -25,10 +25,7 @@ import {
} from '@iptvnator/ui/epg';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
EpgItem,
EpgProgram,
@@ -46,6 +43,7 @@ import {
StreamResolverService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { createPlaybackSessionKey as sessionKey } from '@iptvnator/playback/util';
@Directive({
selector: '[appResizable]',
@@ -125,6 +123,7 @@ class StubAudioPlayerComponent {
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly playback = input<ResolvedPortalPlayback | null>(null);
@@ -586,6 +585,13 @@ describe('UnifiedLiveTabComponent', () => {
expect(webPlayer.streamUrl()).toBe('https://example.com/xtream.m3u8');
expect(webPlayer.title()).toBe('Xtream Live');
const sourceId = item.playlistId;
const contentId = String(item.xtreamId);
const key = sessionKey({ kind: 'live', sourceId, contentId });
expect(webPlayer.playbackSessionKey()).toBe(key);
component.activeItem.set({ ...item, playlistId: 'p2', uid: 'c2' });
expect(component.playbackSessionKey()).not.toBe(key);
component.activeItem.set(item);
const playback = webPlayer.playback();
expect(playback).toEqual(
expect.objectContaining({
@@ -597,15 +603,10 @@ describe('UnifiedLiveTabComponent', () => {
throw new Error('Expected wrapper playback to be set');
}
expect(webPlayer.playerOverride()).toBe(VideoPlayer.VideoJs);
expect(
fixture.nativeElement.querySelector('app-vjs-player')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-html-video-player')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-art-player')
).toBeNull();
const host = fixture.nativeElement;
expect(host.querySelector('app-vjs-player')).toBeNull();
expect(host.querySelector('app-html-video-player')).toBeNull();
expect(host.querySelector('app-art-player')).toBeNull();
webPlayer.externalFallbackRequested.emit({
player: 'mpv',
@@ -622,12 +623,10 @@ describe('UnifiedLiveTabComponent', () => {
},
});
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/xtream.m3u8',
}),
'mpv'
);
const [forwardedPlayback, forwardedPlayer] =
portalPlayer.openExternalPlayback.mock.calls[0];
expect(forwardedPlayback).toBe(playback);
expect(forwardedPlayer).toBe('mpv');
});
it('does not wait for M3U program lookup before opening playback', async () => {
@@ -749,9 +748,9 @@ describe('UnifiedLiveTabComponent', () => {
// 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;
(window.electron as unknown as Record<string, unknown>)[
'setUserAgent'
] = setUserAgent;
const item = {
...buildLiveItem('stalker'),
name: 'Jazz Radio',
@@ -1177,6 +1176,7 @@ describe('UnifiedLiveTabComponent', () => {
'https://example.com/timeshift.m3u8'
);
const item = await selectXtreamArchiveChannel();
const key = component.playbackSessionKey();
component.onTimelineProgramActivated(timeshiftEvent());
await fixture.whenStable();
@@ -1192,6 +1192,7 @@ describe('UnifiedLiveTabComponent', () => {
'https://example.com/timeshift.m3u8'
);
expect(component.inlinePlayback()?.isLive).toBe(false);
expect(component.playbackSessionKey()).toBe(key);
expect(component.activeTimeshiftProgram()?.title).toBe(
'Xtream Show'
);
@@ -62,6 +62,7 @@ import {
import { ResizableDirective } from '@iptvnator/ui/components';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
@Component({
selector: 'app-unified-live-tab',
@@ -123,6 +124,9 @@ export class UnifiedLiveTabComponent {
* portal archive fields (tvArchive/tvArchiveDuration) and to
* supply credentials for Xtream catch-up URL resolution. */
readonly activeItem = signal<UnifiedCollectionItem | null>(null);
readonly playbackSessionKey = computed(() =>
createUnifiedLivePlaybackSessionKey(this.activeItem())
);
readonly isSelecting = signal(false);
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = signal(0);
@@ -266,9 +270,7 @@ export class UnifiedLiveTabComponent {
return getLiveEpgPanelSummary(this.activeDetail());
});
readonly liveEpgPanelSummaryLabelKey = computed(() =>
this.activeTimeshift()
? 'EPG.ARCHIVE_PLAYBACK'
: 'EPG.CURRENT_PROGRAM'
this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM'
);
readonly channelsForList = computed((): UnifiedFavoriteChannel[] =>
@@ -480,10 +482,7 @@ export class UnifiedLiveTabComponent {
program.startTimestamp,
program.start
);
const stopEpoch = toEpochSeconds(
program.stopTimestamp,
program.stop
);
const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop);
if (startEpoch == null || stopEpoch == null) {
this.snackBar.open(
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
@@ -709,5 +708,4 @@ export class UnifiedLiveTabComponent {
};
});
}
}
@@ -8,6 +8,7 @@
<app-stalker-series-view [providerOnly]="providerOnly()" />
} @else if (vodDetailsItem()) {
<app-vod-details
[playbackSessionKey]="playbackSessionKey()"
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
@@ -19,9 +19,21 @@ import { EMPTY, of } from 'rxjs';
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail.component';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
@Component({ selector: 'app-vod-details', template: '' })
class StubVodDetailsComponent {
readonly playbackSessionKey = input.required<string>();
readonly item = input.required<unknown>();
readonly providerOnly = input(false);
readonly isFavorite = input(false);
@@ -47,7 +59,15 @@ class StubStalkerSeriesViewComponent {
describe('StalkerCatalogDetailComponent provider presentation', () => {
let fixture: ComponentFixture<StalkerCatalogDetailComponent>;
const resolveVodPlayback = jest.fn();
const portalPlayer = {
isEmbeddedPlayer: jest.fn(() => true),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
};
const contentType = signal<'vod' | 'series'>('vod');
const catalogPlaylist = signal({ id: 'stalker-1' });
const snackBar = { open: jest.fn() };
const selectedItem = signal<unknown>({
id: '42',
cmd: '/media/42',
@@ -61,6 +81,10 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
cmd: '/media/42',
info: { name: 'Portal movie' },
});
resolveVodPlayback.mockReset();
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
catalogPlaylist.set({ id: 'stalker-1' });
snackBar.open.mockReset();
await TestBed.configureTestingModule({
imports: [StalkerCatalogDetailComponent],
@@ -70,8 +94,9 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
useValue: {
contentType,
selectedItem,
playlist: signal({ id: 'stalker-1' }),
playlist: catalogPlaylist,
clearSelectedItem: jest.fn(),
resolveVodPlayback,
},
},
{
@@ -83,7 +108,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
},
{
provide: PORTAL_PLAYER,
useValue: { isEmbeddedPlayer: jest.fn() },
useValue: portalPlayer,
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
@@ -99,7 +124,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
},
{ provide: DownloadsService, useValue: {} },
{ provide: Router, useValue: { navigateByUrl: jest.fn() } },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{ provide: MatSnackBar, useValue: snackBar },
{
provide: TranslateService,
useValue: {
@@ -139,6 +164,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
).componentInstance as StubVodDetailsComponent;
expect(fixture.componentInstance.providerOnly()).toBe(true);
expect(child.providerOnly()).toBe(true);
expect(child.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-1',
contentId: '42',
})
);
});
it('keeps provider-only presentation disabled for a regular VOD open', async () => {
@@ -154,6 +186,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
).componentInstance as StubVodDetailsComponent;
expect(fixture.componentInstance.providerOnly()).toBe(false);
expect(child.providerOnly()).toBe(false);
expect(child.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-1',
contentId: '99',
})
);
});
it.each([
@@ -178,4 +217,106 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
expect(child.providerOnly()).toBe(true);
}
);
it('does not mount a VOD resolution after the catalog owner changes', async () => {
let resolve!: (value: { streamUrl: string }) => void;
resolveVodPlayback.mockReturnValueOnce(
new Promise((resolvePromise) => {
resolve = resolvePromise;
})
);
fixture.detectChanges();
fixture.componentInstance.onVodPlay({
type: 'stalker',
cmd: '/media/42',
data: selectedItem(),
} as never);
selectedItem.set({
id: '99',
cmd: '/media/99',
info: { name: 'Replacement movie' },
});
fixture.detectChanges();
resolve({ streamUrl: 'https://stale.example/movie.mpg' });
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
});
it('clears committed VOD playback when the playlist changes with the same provider id', async () => {
const playback = { streamUrl: 'https://a.example/movie.mpg' };
resolveVodPlayback.mockResolvedValue(playback);
fixture.detectChanges();
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
catalogPlaylist.set({ id: 'stalker-2' });
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-2',
contentId: '42',
})
);
});
it('does not mount a pending VOD resolution after only the playlist owner changes', async () => {
const pending = deferred<{ streamUrl: string }>();
resolveVodPlayback.mockReturnValue(pending.promise);
fixture.detectChanges();
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
catalogPlaylist.set({ id: 'stalker-2' });
fixture.detectChanges();
pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' });
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
});
it('suppresses a pending VOD error after the playlist owner changes', async () => {
const pending = deferred<{ streamUrl: string }>();
resolveVodPlayback.mockReturnValue(pending.promise);
fixture.detectChanges();
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
catalogPlaylist.set({ id: 'stalker-2' });
fixture.detectChanges();
pending.reject(new Error('stale playlist failure'));
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(snackBar.open).not.toHaveBeenCalled();
});
it('preserves committed playback across a same-owner item refresh', async () => {
const playback = { streamUrl: 'https://a.example/movie.mpg' };
resolveVodPlayback.mockResolvedValue(playback);
fixture.detectChanges();
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
await fixture.whenStable();
selectedItem.set({
...(selectedItem() as Record<string, unknown>),
screenshot_uri: 'refreshed.jpg',
});
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
});
});
@@ -23,6 +23,7 @@ import {
createRefreshTrigger,
isStalkerSeriesFlag,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
@@ -46,6 +47,7 @@ import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { startStalkerVodDownload } from './stalker-vod-download';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
selector: 'app-stalker-catalog-detail',
@@ -76,6 +78,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('StalkerCatalogDetail');
private readonly favoritesRefresh = createRefreshTrigger();
private playbackRequestId = 0;
private currentPlaybackOwnerKey = '';
readonly contentType = this.catalog.contentType;
readonly selectedItem = computed<StalkerSelectedVodItem | null>(
@@ -85,6 +89,16 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly providerOnly = input(false);
readonly playbackSessionKey = computed(() => {
const sourceId = this.catalog.playlist()?.id;
const contentId = normalizeStalkerEntityId(this.selectedItem()?.id);
return sourceId && contentId
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
private readonly playbackOwnerKey = computed(() =>
JSON.stringify([this.playbackSessionKey(), this.contentType()])
);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
@@ -147,8 +161,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
});
effect(() => {
const selectedItemId = this.selectedItem()?.id;
void selectedItemId;
const ownerKey = this.playbackOwnerKey();
if (ownerKey === this.currentPlaybackOwnerKey) return;
this.currentPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
});
@@ -236,6 +251,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
}
closeInlinePlayer(): void {
this.playbackRequestId += 1;
this.inlinePlayback.set(null);
this.positionWriter.reset();
}
@@ -305,6 +321,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
thumbnail?: string,
startTime?: number
): Promise<void> {
const requestId = ++this.playbackRequestId;
const sessionKey = this.playbackSessionKey();
const ownerKey = this.playbackOwnerKey();
const usesEmbeddedPlayer = this.portalPlayer.isEmbeddedPlayer();
if (usesEmbeddedPlayer && !sessionKey) return;
try {
const playback = await this.catalog.resolveVodPlayback(
cmd,
@@ -312,9 +334,15 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
thumbnail,
startTime
);
if (
requestId !== this.playbackRequestId ||
this.playbackOwnerKey() !== ownerKey
) {
return;
}
this.positionWriter.reset();
if (this.portalPlayer.isEmbeddedPlayer()) {
if (usesEmbeddedPlayer) {
this.inlinePlayback.set(playback);
return;
}
@@ -322,6 +350,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
if (
requestId !== this.playbackRequestId ||
this.playbackOwnerKey() !== ownerKey
) {
return;
}
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
@@ -1,5 +1,6 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from '@iptvnator/ui/components';
@@ -20,6 +21,7 @@ import {
import { of } from 'rxjs';
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
selector: 'app-content-hero',
@@ -36,6 +38,7 @@ class StubContentHeroComponent {
template: '',
})
class StubStalkerInlineDetailComponent {
readonly playbackSessionKey = input.required<string>();
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<unknown>(null);
readonly isSeries = input(false);
@@ -89,6 +92,8 @@ describe('StalkerCollectionDetailComponent', () => {
getAllPlaybackPositions: jest.Mock;
clearPlaybackPosition: jest.Mock;
};
let snackBar: { open: jest.Mock };
let playlistsService: { getPlaylistById: jest.Mock };
const playlist = {
_id: 'stalker-1',
@@ -140,6 +145,12 @@ describe('StalkerCollectionDetailComponent', () => {
getAllPlaybackPositions: jest.fn(),
clearPlaybackPosition: jest.fn(),
};
snackBar = { open: jest.fn() };
playlistsService = {
getPlaylistById: jest.fn((playlistId: string) =>
of({ ...playlist, _id: playlistId })
),
};
await TestBed.configureTestingModule({
imports: [StalkerCollectionDetailComponent],
@@ -167,9 +178,7 @@ describe('StalkerCollectionDetailComponent', () => {
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
useValue: snackBar,
},
{
provide: TranslateService,
@@ -185,7 +194,7 @@ describe('StalkerCollectionDetailComponent', () => {
{
provide: PlaylistsService,
useValue: {
getPlaylistById: jest.fn(() => of(playlist)),
getPlaylistById: playlistsService.getPlaylistById,
getPortalFavorites: jest.fn(() => of([])),
},
},
@@ -315,9 +324,7 @@ describe('StalkerCollectionDetailComponent', () => {
).toEqual([1, 2]);
// The snapshot renders immediately, but a background portal
// re-fetch must be triggered so new episodes can appear.
expect(
stalkerStore.refreshEmbeddedSeriesSelection
).toHaveBeenCalled();
expect(stalkerStore.refreshEmbeddedSeriesSelection).toHaveBeenCalled();
});
it('shows newly released episodes when the background snapshot refresh patches the selection', async () => {
@@ -393,6 +400,131 @@ describe('StalkerCollectionDetailComponent', () => {
expect(stalkerStore.createLinkToPlayVod).not.toHaveBeenCalled();
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(fixture.componentInstance.inlinePlayback()).toEqual(playback);
const detail = fixture.debugElement.query(
By.directive(StubStalkerInlineDetailComponent)
).componentInstance as StubStalkerInlineDetailComponent;
expect(detail.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-1',
contentId: '1701',
})
);
});
it('ignores a pending payload after the collection detail owner changes and lets the new owner commit', async () => {
const pendingA = deferred<ResolvedPortalPlayback>();
const pendingB = deferred<ResolvedPortalPlayback>();
stalkerStore.resolveVodPlayback.mockImplementation((cmd: string) =>
cmd.includes('1701') ? pendingA.promise : pendingB.promise
);
const itemA = buildCollectionItem({
stalkerId: '1701',
stalkerItem: buildVodSource('1701', 'Movie A'),
});
const itemB = buildCollectionItem({
stalkerId: '1702',
stalkerItem: buildVodSource('1702', 'Movie B'),
});
fixture.componentRef.setInput('item', itemA);
await settleDetail(fixture);
fixture.componentInstance.onVodPlay(
createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId)
);
await Promise.resolve();
fixture.componentRef.setInput('item', itemB);
await settleDetail(fixture);
pendingA.resolve({ streamUrl: 'https://streams.test/a.mp4' });
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(snackBar.open).not.toHaveBeenCalled();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-1',
contentId: '1702',
})
);
fixture.componentInstance.onVodPlay(
createStalkerVodItem(itemB.stalkerItem as never, itemB.playlistId)
);
pendingB.resolve({ streamUrl: 'https://streams.test/b.mp4' });
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toEqual({
streamUrl: 'https://streams.test/b.mp4',
});
});
it('suppresses a pending error after the collection playlist owner changes', async () => {
const pending = deferred<ResolvedPortalPlayback>();
stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise);
const source = buildVodSource('1701', 'Movie A');
const itemA = buildCollectionItem({
playlistId: 'stalker-1',
stalkerId: '1701',
stalkerItem: source,
});
const itemB = buildCollectionItem({
playlistId: 'stalker-2',
stalkerId: '1701',
stalkerItem: { ...source },
});
fixture.componentRef.setInput('item', itemA);
await settleDetail(fixture);
fixture.componentInstance.onVodPlay(
createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId)
);
await Promise.resolve();
fixture.componentRef.setInput('item', itemB);
await settleDetail(fixture);
pending.reject(new Error('stale A failure'));
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(snackBar.open).not.toHaveBeenCalled();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-2',
contentId: '1701',
})
);
});
it('keeps a pending request when the same canonical owner is refreshed with a new object', async () => {
const pending = deferred<ResolvedPortalPlayback>();
stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise);
const item = buildCollectionItem({
stalkerId: '1701',
stalkerItem: buildVodSource('1701', 'Movie A'),
});
fixture.componentRef.setInput('item', item);
await settleDetail(fixture);
fixture.componentInstance.onVodPlay(
createStalkerVodItem(item.stalkerItem as never, item.playlistId)
);
await Promise.resolve();
fixture.componentRef.setInput('item', {
...item,
stalkerItem: { ...(item.stalkerItem as object) },
});
await settleDetail(fixture);
pending.resolve({ streamUrl: 'https://streams.test/a.mp4' });
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toEqual({
streamUrl: 'https://streams.test/a.mp4',
});
expect(snackBar.open).not.toHaveBeenCalled();
});
it('does not load VOD playback position when the playlist id is missing', async () => {
@@ -444,6 +576,26 @@ function buildCollectionItem(
};
}
function buildVodSource(id: string, title: string) {
return {
id,
title,
category_id: 'vod',
cmd: `/media/file_${id}.mpg`,
info: { name: title },
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function settleDetail(
fixture: ComponentFixture<StalkerCollectionDetailComponent>
): Promise<void> {
@@ -48,6 +48,7 @@ import {
import { firstValueFrom } from 'rxjs';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
interface StalkerCollectionStateSnapshot {
currentPlaylist: Playlist | undefined;
@@ -65,12 +66,19 @@ interface StalkerCollectionDetailMode {
needsSeriesFetch: boolean;
}
interface StalkerCollectionPlaybackOwner {
readonly sourceId: string;
readonly contentId: string;
readonly sessionKey: string;
}
@Component({
selector: 'app-stalker-collection-detail',
imports: [PortalDetailShellComponent, StalkerInlineDetailComponent],
template: `
@if (inlineDetail().categoryId) {
<app-stalker-inline-detail
[playbackSessionKey]="playbackSessionKey()"
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
@@ -124,6 +132,12 @@ export class StalkerCollectionDetailComponent {
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly playbackOwner = computed(() =>
captureStalkerCollectionPlaybackOwner(this.item())
);
readonly playbackSessionKey = computed(
() => this.playbackOwner()?.sessionKey ?? ''
);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
@@ -149,6 +163,7 @@ export class StalkerCollectionDetailComponent {
);
private initRequestId = 0;
private currentPlaybackOwnerKey = '';
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
@@ -158,6 +173,7 @@ export class StalkerCollectionDetailComponent {
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start collection VOD playback',
playbackOwnerKey: () => this.playbackSessionKey(),
});
constructor() {
@@ -168,7 +184,12 @@ export class StalkerCollectionDetailComponent {
effect(() => {
const item = this.item();
const playbackOwnerKey = this.playbackSessionKey();
untracked(() => {
if (playbackOwnerKey !== this.currentPlaybackOwnerKey) {
this.currentPlaybackOwnerKey = playbackOwnerKey;
this.closeInlinePlayer();
}
void this.prepareDetail(item);
});
});
@@ -490,3 +511,31 @@ export class StalkerCollectionDetailComponent {
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
}
}
function captureStalkerCollectionPlaybackOwner(
item: UnifiedCollectionItem | null
): StalkerCollectionPlaybackOwner | null {
if (!item) return null;
const sourceId = item.playlistId.trim();
const providerItem = item.stalkerItem as
{ id?: unknown; stream_id?: unknown } | undefined;
const uidParts = item.uid.split('::');
const contentId = normalizeStalkerEntityId(
providerItem?.id ??
providerItem?.stream_id ??
item.stalkerId ??
uidParts[uidParts.length - 1]
);
if (!sourceId || !contentId) return null;
return Object.freeze({
sourceId,
contentId,
sessionKey: createPlaybackSessionKey({
kind: 'vod',
sourceId,
contentId,
}),
});
}
@@ -15,6 +15,7 @@
} @else if (vodDetailsItem(); as item) {
<!-- Regular VOD item -->
<app-vod-details
[playbackSessionKey]="playbackSessionKey()"
[item]="item"
[isFavorite]="isFavorite()"
[playbackPosition]="playbackPosition()"
@@ -8,6 +8,7 @@ import { StalkerInlineDetailComponent } from './stalker-inline-detail.component'
@Component({ selector: 'app-vod-details', template: '' })
class StubVodDetailsComponent {
readonly playbackSessionKey = input.required<string>();
readonly item = input.required<unknown>();
readonly providerOnly = input(false);
readonly isFavorite = input(false);
@@ -57,6 +58,10 @@ describe('StalkerInlineDetailComponent provider presentation', () => {
.compileComponents();
fixture = TestBed.createComponent(StalkerInlineDetailComponent);
fixture.componentRef.setInput('providerOnly', true);
fixture.componentRef.setInput(
'playbackSessionKey',
'collection-owned-vod-key'
);
});
afterEach(() => fixture.destroy());
@@ -70,6 +75,7 @@ describe('StalkerInlineDetailComponent provider presentation', () => {
By.directive(StubVodDetailsComponent)
).componentInstance as StubVodDetailsComponent;
expect(child.providerOnly()).toBe(true);
expect(child.playbackSessionKey()).toBe('collection-owned-vod-key');
});
it.each([
@@ -32,6 +32,7 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie
],
})
export class StalkerInlineDetailComponent {
readonly playbackSessionKey = input.required<string>();
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
readonly isSeries = input<boolean>(false);
@@ -32,7 +32,9 @@
mat-icon-button
type="button"
[disabled]="isFullListLoading()"
[attr.aria-label]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
[attr.aria-label]="
'PORTALS.REFRESH_CHANNEL_LIST' | translate
"
[matTooltip]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
(click)="refreshChannels()"
>
@@ -83,7 +85,9 @@
<div class="empty-search-state">
<app-portal-empty-state
icon="tv_off"
[message]="'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate"
[message]="
'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate
"
/>
</div>
} @else {
@@ -120,7 +124,9 @@
(clicked)="playChannel(item)"
(activated)="playChannel(item, true)"
(favoriteToggled)="toggleFavorite(item)"
(contextMenuRequested)="onChannelContextMenu(item, $event)"
(contextMenuRequested)="
onChannelContextMenu(item, $event)
"
/>
}
}
@@ -180,6 +186,7 @@
/>
} @else {
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="streamUrl()"
[title]="activePlayback()?.title ?? ''"
[playback]="activePlayback()"
@@ -17,11 +17,11 @@ import {
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
import {
EpgListViewComponent,
EpgTimelineComponent,
} from '@iptvnator/ui/epg';
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
AudioPlayerComponent,
type PlaybackFallbackRequest,
} from '@iptvnator/ui/playback';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ChannelListItemComponent } from '@iptvnator/ui/components';
import { MockPipe } from 'ng-mocks';
@@ -31,7 +31,10 @@ import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
EpgProgram,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { MatDialog } from '@angular/material/dialog';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
@@ -66,10 +69,11 @@ class StubChannelListItemComponent {
template: '',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly externalFallbackRequested = output<unknown>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
}
@Component({
@@ -202,9 +206,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
loaded: number;
total: number;
} | null>(null);
const itvFullChannelList = signal<
ReturnType<typeof defaultItvChannels>
>([]);
const itvFullChannelList = signal<ReturnType<typeof defaultItvChannels>>(
[]
);
const itvSelectedCategoryFromCache = signal(false);
const isPaginatedContentLoading = signal(false);
@@ -266,6 +270,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
const portalPlayer = {
isEmbeddedPlayer: jest.fn(() => true),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
};
const settingsStore = {
openStreamOnDoubleClick: signal(false),
@@ -277,6 +282,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
// The store mock is module-scoped: reset so a failed test can't leak
// 'list' into siblings.
settingsStore.resolvedEpgViewMode.set('timeline');
playlist.set({ _id: 'playlist-1', title: 'Demo Stalker' });
window.electron = {
platform: 'darwin',
setUserAgent: jest.fn().mockResolvedValue(true),
@@ -327,6 +333,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
portalPlayer.isEmbeddedPlayer.mockReset();
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
portalPlayer.openResolvedPlayback.mockClear();
portalPlayer.openExternalPlayback.mockClear();
fetchChannelEpg.mockReset();
fetchChannelEpg.mockResolvedValue([]);
ensureBulkItvEpg.mockReset();
@@ -407,9 +414,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
useValue: {
supportsEpgMapping: false,
getEpgMapping: jest.fn().mockResolvedValue(null),
getEpgMappingsBatch: jest
.fn()
.mockResolvedValue(null),
getEpgMappingsBatch: jest.fn().mockResolvedValue(null),
},
},
],
@@ -503,7 +508,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
fixture.nativeElement.querySelector('app-web-player-view')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
const channelRows = fixture.debugElement.queryAll(
By.directive(StubChannelListItemComponent)
);
@@ -526,6 +533,44 @@ describe('StalkerLiveStreamLayoutComponent', () => {
).toBe(true);
});
it('forwards the exact resolved live playback to external fallback', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/stalker-fallback.m3u8',
title: 'Alpha TV',
isLive: true,
headers: { Authorization: 'Bearer token' },
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 10001,
contentType: 'live',
},
};
const request: PlaybackFallbackRequest = {
player: 'vlc',
playback,
diagnostic: {
code: 'network-error',
player: 'html5',
source: 'hls',
container: '',
mimeType: '',
videoCodecs: [],
audioCodecs: [],
externalFallbackRecommended: true,
},
};
component.handleExternalFallbackRequest(request);
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
playback,
'vlc'
);
expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe(
playback
);
});
it('renders radio channel rows with compact no-EPG density', () => {
stalkerStore.selectedContentType.set('radio');
@@ -1115,7 +1160,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
expect(fetchChannelEpg).not.toHaveBeenCalled();
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-audio-player')
).not.toBeNull();
@@ -1185,14 +1232,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
);
// 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);
});
});
(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',
@@ -76,12 +76,24 @@ import {
normalizeStalkerEntityId,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
type StalkerPlayableChannel = StalkerPortalItem & {
cmd?: string;
has_files?: unknown;
};
interface StalkerActiveLivePlaybackIdentity {
readonly sourceId: string;
readonly contentId: string;
}
interface StalkerPlaybackResolutionOwner {
readonly sourceId: string;
readonly contentType: string;
readonly channelId: string;
}
/** Channels rendered per "page" when the full list is served from the cache. */
const FULL_LIST_RENDER_CHUNK = 100;
@@ -270,6 +282,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.portalPlayer.isEmbeddedPlayer()
);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly activePlaybackIdentity =
signal<StalkerActiveLivePlaybackIdentity | null>(null);
readonly playbackSessionKey = computed(() => {
const identity = this.activePlaybackIdentity();
return identity
? createPlaybackSessionKey({ kind: 'live', ...identity })
: '';
});
readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? '');
readonly activePlaybackTitle = computed(
() =>
@@ -366,7 +386,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
/** Stream URL of the radio playback whose header override this layout configured. */
private radioHeaderScopeUrl: string | null = null;
private playbackResolution: {
channelId: string;
ownerKey: string;
promise: Promise<ResolvedPortalPlayback>;
} | null = null;
private lastPlaylistId: string | null | undefined = undefined;
@@ -414,9 +434,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
untracked(() => {
if (contentType === 'radio') {
this.stalkerStore.setRadioChannels([]);
} else if (
!this.stalkerStore.itvSelectedCategoryFromCache()
) {
} else if (!this.stalkerStore.itvSelectedCategoryFromCache()) {
this.stalkerStore.setItvChannels([]);
}
this.stalkerStore.setPage(0);
@@ -449,8 +467,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
// store dedupes per channel id, so this is cheap on rerenders.
if (this.supportsEpgMapping && channels.length > 0) {
const channelIds = channels.map((channel) => channel.id);
untracked(() =>
void this.stalkerStore.applyMappedItvEpg(channelIds)
untracked(
() => void this.stalkerStore.applyMappedItvEpg(channelIds)
);
}
});
@@ -567,6 +585,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
// override slot.
this.playbackRequestId += 1;
this.streamHeaders.clear(this.radioHeaderScopeUrl);
this.clearActivePlayback();
}
isSelectedChannel(item: StalkerItvChannel): boolean {
@@ -581,6 +600,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
) {
const requestId = ++this.playbackRequestId;
const channelId = normalizeStalkerEntityId(item.id);
const sourceId = normalizeStalkerEntityId(
this.stalkerStore.currentPlaylist()?._id
);
const contentType = this.stalkerStore.selectedContentType();
const isRadioMode = this.isRadioMode();
this.stalkerStore.setSelectedItem(item);
this.ensureChannelWithinRenderWindow(channelId);
// A previously owned radio override must not survive into a
@@ -591,15 +615,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.radioHeaderScopeUrl = null;
try {
const isRadioMode = this.isRadioMode();
const playback = await this.resolvePlaybackForChannel(
item,
channelId
{ sourceId, contentType, channelId },
isRadioMode
);
if (
requestId !== this.playbackRequestId ||
this.selectedChannelId() !== channelId
) {
const owner = { sourceId, contentType, channelId };
if (!this.isPlaybackRequestCurrent(requestId, owner)) {
return;
}
@@ -618,12 +640,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const stillCurrent = headerSync ? await headerSync : true;
if (
!stillCurrent ||
requestId !== this.playbackRequestId ||
this.selectedChannelId() !== channelId
!this.isPlaybackRequestCurrent(requestId, owner)
) {
return;
}
this.activePlayback.set(playback);
this.setActivePlayback(playback, null);
return;
}
@@ -632,12 +653,22 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
if (this.usesEmbeddedPlayer()) {
this.activePlayback.set(playback);
if (!sourceId || !channelId) return;
this.setActivePlayback(playback, {
sourceId,
contentId: channelId,
});
} else if (startPlayback) {
void this.portalPlayer.openResolvedPlayback(playback, true);
}
} catch (error) {
if (requestId !== this.playbackRequestId) {
if (
!this.isPlaybackRequestCurrent(requestId, {
sourceId,
contentType,
channelId,
})
) {
return;
}
@@ -652,21 +683,19 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private resolvePlaybackForChannel(
item: StalkerItvChannel,
channelId: string
owner: StalkerPlaybackResolutionOwner,
isRadioMode: boolean
): Promise<ResolvedPortalPlayback> {
const playbackChannelId = [
this.stalkerStore.selectedContentType(),
channelId,
].join(':');
if (this.playbackResolution?.channelId === playbackChannelId) {
const ownerKey = JSON.stringify(owner);
if (this.playbackResolution?.ownerKey === ownerKey) {
return this.playbackResolution.promise;
}
const playableItem = this.toPlayableChannel(item);
const promise = this.isRadioMode()
const promise = isRadioMode
? this.stalkerStore.resolveRadioPlayback(playableItem)
: this.stalkerStore.resolveItvPlayback(playableItem);
this.playbackResolution = { channelId: playbackChannelId, promise };
this.playbackResolution = { ownerKey, promise };
const cleanup = () => {
if (this.playbackResolution?.promise === promise) {
@@ -679,6 +708,33 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
return promise;
}
private isPlaybackRequestCurrent(
requestId: number,
owner: StalkerPlaybackResolutionOwner
): boolean {
return (
requestId === this.playbackRequestId &&
this.selectedChannelId() === owner.channelId &&
normalizeStalkerEntityId(
this.stalkerStore.currentPlaylist()?._id
) === owner.sourceId &&
this.stalkerStore.selectedContentType() === owner.contentType
);
}
private setActivePlayback(
playback: ResolvedPortalPlayback,
identity: StalkerActiveLivePlaybackIdentity | null
): void {
this.activePlaybackIdentity.set(identity);
this.activePlayback.set(playback);
}
private clearActivePlayback(): void {
this.activePlaybackIdentity.set(null);
this.activePlayback.set(null);
}
toggleFavorite(item: StalkerItvChannel) {
const itemId = normalizeStalkerEntityId(item.id);
if (this.favorites.has(itemId)) {
@@ -0,0 +1,294 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
let component: StalkerLiveStreamLayoutComponent;
const playlist = signal({ _id: 'playlist-one', title: 'Portal One' });
const channels = [
{
id: 'channel-one',
cmd: 'ffrt4://itv/channel-one',
name: 'One',
o_name: 'One',
logo: 'one.png',
},
{
id: 'channel-two',
cmd: 'ffrt4://itv/channel-two',
name: 'Two',
o_name: 'Two',
logo: 'two.png',
},
];
const itvChannels = signal(channels);
const selectedItvId = signal<string | undefined>(channels[0].id);
const selectedItem = signal<(typeof channels)[number] | null>(channels[0]);
const selectedContentType = signal<'itv' | 'radio'>('itv');
const resolveItvPlayback = jest.fn();
const snackBar = { open: jest.fn() };
const store = {
getSelectedCategoryName: signal('All'),
currentPlaylist: playlist,
selectedContentType,
selectedCategoryId: signal<string | null>('all'),
selectedItvId,
selectedItem,
itvChannels,
radioChannels: signal([]),
searchPhrase: signal(''),
hasMoreChannels: signal(false),
itvFullListActive: signal(false),
itvSelectedCategoryFromCache: signal(false),
itvFullListLoading: signal(false),
itvFullListProgress: signal(null),
itvFullChannelList: signal([]),
isPaginatedContentLoading: signal(false),
selectedItvEpgPrograms: signal([]),
bulkItvEpgByChannel: signal({}),
isLoadingBulkItvEpg: signal(false),
setItvChannels: jest.fn(),
setRadioChannels: jest.fn(),
setPage: jest.fn(),
preloadItvChannels: jest.fn(),
applyMappedItvEpg: jest.fn(),
clearBulkItvEpgCache: jest.fn(),
ensureBulkItvEpg: jest.fn(),
fetchChannelEpg: jest.fn(),
resolveItvPlayback,
resolveRadioPlayback: jest.fn(),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
setSelectedItem: jest.fn((item: (typeof channels)[number]) => {
selectedItem.set(item);
selectedItvId.set(String(item.id));
}),
};
beforeEach(async () => {
playlist.set({ _id: 'playlist-one', title: 'Portal One' });
selectedContentType.set('itv');
selectedItvId.set(channels[0].id);
selectedItem.set(channels[0]);
resolveItvPlayback.mockReset();
snackBar.open.mockReset();
store.setSelectedItem.mockClear();
await TestBed.configureTestingModule({
imports: [StalkerLiveStreamLayoutComponent],
providers: [
{ provide: StalkerStore, useValue: store },
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsEpg: false,
isElectron: false,
supportsEpgMapping: false,
},
},
{
provide: PlaylistsService,
useValue: { getPortalFavorites: () => of([]) },
},
{
provide: SettingsStore,
useValue: { openStreamOnDoubleClick: signal(false) },
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openResolvedPlayback: jest.fn(),
},
},
{
provide: ElectronStreamHeadersService,
useValue: { apply: jest.fn(), clear: jest.fn() },
},
{
provide: LiveLayoutSidebarStateService,
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
},
{ provide: EpgRuntimeBridgeService, useValue: {} },
{ provide: MatDialog, useValue: { open: jest.fn() } },
{ provide: MatSnackBar, useValue: snackBar },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
],
})
.overrideComponent(StalkerLiveStreamLayoutComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => fixture.destroy());
it('changes identity only when the current embedded playback commits', async () => {
const firstPlayback = { streamUrl: 'https://one.example/live.m3u8' };
resolveItvPlayback.mockResolvedValueOnce(firstPlayback);
await component.playChannel(channels[0]);
const firstKey = component.playbackSessionKey();
const session = () => [
component.activePlayback(),
component.playbackSessionKey(),
];
expect(session()).toEqual([firstPlayback, firstKey]);
const failed = deferred<ResolvedPortalPlayback>();
resolveItvPlayback.mockReturnValueOnce(failed.promise);
const failedSelection = component.playChannel(channels[1]);
failed.reject(new Error('resolution failed'));
await failedSelection;
expect(session()).toEqual([firstPlayback, firstKey]);
const stale = deferred<ResolvedPortalPlayback>();
const current = deferred<ResolvedPortalPlayback>();
resolveItvPlayback
.mockReturnValueOnce(stale.promise)
.mockReturnValueOnce(current.promise);
const staleSelection = component.playChannel(channels[0]);
const currentSelection = component.playChannel(channels[1]);
const currentPlayback = {
streamUrl: 'https://two.example/live.m3u8',
};
current.resolve(currentPlayback);
stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
await Promise.all([staleSelection, currentSelection]);
expect(session()).toEqual([
currentPlayback,
createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist-one',
contentId: 'channel-two',
}),
]);
});
it('does not dedupe the same channel id across playlist owners', async () => {
const sourceA = deferred<ResolvedPortalPlayback>();
const sourceB = deferred<ResolvedPortalPlayback>();
resolveItvPlayback
.mockReturnValueOnce(sourceA.promise)
.mockReturnValueOnce(sourceB.promise);
const shared = { ...channels[0], id: 'shared-channel' };
playlist.set({ _id: 'playlist-a', title: 'Portal A' });
const requestA = component.playChannel(shared);
playlist.set({ _id: 'playlist-b', title: 'Portal B' });
const requestB = component.playChannel(shared);
const playbackB = { streamUrl: 'https://b.example/shared.m3u8' };
sourceB.resolve(playbackB);
sourceA.resolve({ streamUrl: 'https://a.example/shared.m3u8' });
await Promise.all([requestA, requestB]);
expect(resolveItvPlayback).toHaveBeenCalledTimes(2);
expect(component.activePlayback()).toBe(playbackB);
expect(component.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist-b',
contentId: 'shared-channel',
})
);
});
it('preserves the committed pair when source or channel identity is absent', async () => {
const valid = { streamUrl: 'https://valid.example/live.m3u8' };
resolveItvPlayback.mockResolvedValueOnce(valid);
await component.playChannel(channels[0]);
const key = component.playbackSessionKey();
playlist.set({ _id: '', title: 'Missing source' });
resolveItvPlayback.mockResolvedValueOnce({
streamUrl: 'https://invalid.example/source.m3u8',
});
await component.playChannel(channels[1]);
expect([
component.activePlayback(),
component.playbackSessionKey(),
]).toEqual([valid, key]);
playlist.set({ _id: 'playlist-one', title: 'Portal One' });
resolveItvPlayback.mockResolvedValueOnce({
streamUrl: 'https://invalid.example/channel.m3u8',
});
await component.playChannel({ ...channels[1], id: '' });
expect([
component.activePlayback(),
component.playbackSessionKey(),
]).toEqual([valid, key]);
});
it('does not commit a pending ITV request after the mode changes to radio with the same channel id', async () => {
const pending = deferred<ResolvedPortalPlayback>();
resolveItvPlayback.mockReturnValue(pending.promise);
const request = component.playChannel(channels[0]);
selectedContentType.set('radio');
pending.resolve({ streamUrl: 'https://stale.example/itv.m3u8' });
await request;
expect(component.activePlayback()).toBeNull();
expect(component.playbackSessionKey()).toBe('');
});
it('suppresses a stale rejection after playlist, mode, and channel ownership change', async () => {
const pending = deferred<ResolvedPortalPlayback>();
resolveItvPlayback.mockReturnValue(pending.promise);
const request = component.playChannel(channels[0]);
playlist.set({ _id: 'playlist-two', title: 'Portal Two' });
selectedContentType.set('radio');
selectedItvId.set(channels[1].id);
pending.reject(new Error('stale owner failure'));
await request;
expect(snackBar.open).not.toHaveBeenCalled();
});
it('still reports an error for the current full owner', async () => {
resolveItvPlayback.mockRejectedValueOnce(new Error('current failure'));
await component.playChannel(channels[0]);
expect(snackBar.open).toHaveBeenCalledWith(
'PORTALS.PLAYBACK_ERROR',
undefined,
{ duration: 3000 }
);
});
});
@@ -12,6 +12,7 @@
@if (showingDetails) {
<app-stalker-inline-detail
details
[playbackSessionKey]="playbackSessionKey()"
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
@@ -0,0 +1,273 @@
import { Location } from '@angular/common';
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
StalkerPortalRepairService,
StalkerSessionService,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { DataService, PlaylistsService } from '@iptvnator/services';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { StalkerSearchComponent } from './stalker-search.component';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
@Component({
selector: 'app-stalker-inline-detail',
standalone: true,
template: '',
})
class StubStalkerInlineDetailComponent {
readonly playbackSessionKey = input.required<string>();
readonly inlinePlayback = input<unknown>(null);
readonly inlinePlaybackClosed = output<void>();
}
describe('StalkerSearchComponent playback session key', () => {
let fixture: ComponentFixture<StalkerSearchComponent>;
const playlist = signal({
_id: 'playlist|one',
title: 'Search portal',
portalUrl: 'https://stalker.example',
macAddress: '00:1A:79:12:34:56',
});
const selectedItem = signal<unknown>(null);
const resolveVodPlayback = jest.fn();
const snackBar = { open: jest.fn() };
beforeEach(async () => {
playlist.set({
_id: 'playlist|one',
title: 'Search portal',
portalUrl: 'https://stalker.example',
macAddress: '00:1A:79:12:34:56',
});
selectedItem.set(null);
resolveVodPlayback.mockReset();
snackBar.open.mockReset();
await TestBed.configureTestingModule({
imports: [StalkerSearchComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
queryParamMap: of(convertToParamMap({})),
snapshot: {
data: {},
queryParamMap: convertToParamMap({}),
routeConfig: { path: 'search' },
},
},
},
{ provide: Location, useValue: { back: jest.fn() } },
{ provide: DataService, useValue: {} },
{
provide: PlaylistContextFacade,
useValue: { activePlaylist: playlist },
},
{
provide: PlaylistsService,
useValue: { getPortalFavorites: () => of([]) },
},
{
provide: StalkerStore,
useValue: {
selectedItem,
setSelectedContentType: jest.fn(),
setSelectedItem: jest.fn((item) =>
selectedItem.set(item)
),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
resolveVodPlayback,
},
},
{ provide: StalkerSessionService, useValue: {} },
{ provide: StalkerPortalRepairService, useValue: {} },
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: signal(null) },
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition: jest.fn().mockResolvedValue(null),
savePlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
},
},
{ provide: MatSnackBar, useValue: snackBar },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
],
})
.overrideComponent(StalkerSearchComponent, {
set: {
imports: [StubStalkerInlineDetailComponent],
template: `
@if (showingDetails) {
<app-stalker-inline-detail
[playbackSessionKey]="playbackSessionKey()"
[inlinePlayback]="inlinePlayback()"
(inlinePlaybackClosed)="closeInlinePlayer()"
/>
}
`,
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerSearchComponent);
});
afterEach(() => fixture?.destroy());
it('owns the original item identity and threads it unchanged through payload replacement', () => {
const first = {
id: 'movie|one',
cmd: 'ffrt4://movie/one',
name: 'Movie One',
info: { name: 'Movie One' },
};
fixture.componentInstance.selectItem(first);
fixture.detectChanges();
const expected = createPlaybackSessionKey({
kind: 'vod',
sourceId: 'playlist|one',
contentId: 'movie|one',
});
const detail = fixture.debugElement.query(
By.directive(StubStalkerInlineDetailComponent)
).componentInstance as StubStalkerInlineDetailComponent;
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
expect(detail.playbackSessionKey()).toBe(expected);
fixture.componentInstance.inlinePlayback.set({
streamUrl: 'https://alternative.example/replaced.mkv',
title: 'Alternative movie',
contentInfo: {
playlistId: 'alternative-playlist',
contentXtreamId: 999,
contentType: 'vod',
},
});
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
fixture.componentInstance.selectItem({
...first,
id: 'movie|two',
});
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
expected
);
const itemChangedKey = fixture.componentInstance.playbackSessionKey();
playlist.set({ ...playlist(), _id: 'playlist|two' });
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
itemChangedKey
);
});
it('clears committed playback when the route playlist owner changes for the same provider id', async () => {
const item = {
id: 'movie|one',
cmd: 'ffrt4://movie/one',
name: 'Movie One',
info: { name: 'Movie One' },
};
resolveVodPlayback.mockResolvedValue({
streamUrl: 'https://a.example/movie.mpg',
});
fixture.componentInstance.selectItem(item);
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).not.toBeNull();
playlist.set({ ...playlist(), _id: 'playlist|two' });
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'playlist|two',
contentId: 'movie|one',
})
);
});
it('suppresses a pending old-playlist error for the same provider id', async () => {
const pending = deferred<ResolvedPortalPlayback>();
resolveVodPlayback.mockReturnValue(pending.promise);
fixture.componentInstance.selectItem({
id: 'movie|one',
cmd: 'ffrt4://movie/one',
name: 'Movie One',
info: { name: 'Movie One' },
});
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
await Promise.resolve();
playlist.set({ ...playlist(), _id: 'playlist|two' });
fixture.detectChanges();
pending.reject(new Error('stale playlist failure'));
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(snackBar.open).not.toHaveBeenCalled();
});
it('preserves committed playback when the same canonical owner object refreshes', async () => {
const item = {
id: 'movie|one',
cmd: 'ffrt4://movie/one',
name: 'Movie One',
info: { name: 'Movie One' },
};
const playback = { streamUrl: 'https://a.example/movie.mpg' };
resolveVodPlayback.mockResolvedValue(playback);
fixture.componentInstance.selectItem(item);
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
await fixture.whenStable();
selectedItem.set({ ...item, screenshot_uri: 'refreshed.jpg' });
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
});
});
@@ -6,6 +6,7 @@ import {
inject,
resource,
signal,
untracked,
} from '@angular/core';
import { Location } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -57,6 +58,7 @@ import {
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
interface StalkerFilter {
key: StalkerSearchContentType;
@@ -103,6 +105,7 @@ export class StalkerSearchComponent {
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerSearch');
private currentPlaybackOwnerKey = '';
readonly filters = signal<Record<StalkerSearchContentType, boolean>>({
series: false,
@@ -141,6 +144,16 @@ export class StalkerSearchComponent {
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly playbackSessionKey = computed(() => {
const sourceId = this.currentPlaylist()?._id;
const contentId = normalizeStalkerEntityId(this.itemDetails()?.id);
return sourceId && contentId
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
private readonly playbackOwnerKey = computed(() =>
JSON.stringify([this.playbackSessionKey(), this.selectedFilterType()])
);
readonly selectedVodPosition = signal<PlaybackPositionData | null>(null);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
@@ -154,6 +167,7 @@ export class StalkerSearchComponent {
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start search VOD playback',
playbackOwnerKey: () => this.playbackOwnerKey(),
});
readonly portalFavorites = createPortalFavoritesResource(
@@ -197,16 +211,15 @@ export class StalkerSearchComponent {
// executeStalkerRequest owns the portal-mode decision (shared
// predicate with URL fallback for legacy rows) and the lazy
// portal repair, so search cannot drift from the catalog paths.
const response =
await executeStalkerRequest<StalkerSearchResponse>(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
portalRepair: this.portalRepair,
},
playlist,
requestParams
);
const response = await executeStalkerRequest<StalkerSearchResponse>(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
portalRepair: this.portalRepair,
},
playlist,
requestParams
);
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
@@ -217,6 +230,12 @@ export class StalkerSearchComponent {
readonly isSelectedVodFavorite = signal<boolean>(false);
constructor() {
this.currentPlaybackOwnerKey = this.playbackOwnerKey();
effect(() => {
const ownerKey = this.playbackOwnerKey();
untracked(() => this.syncPlaybackOwner(ownerKey));
});
effect(() => {
const routeTerm = this.routeSearchTerm();
if (routeTerm !== this.searchTerm()) {
@@ -293,7 +312,6 @@ export class StalkerSearchComponent {
}
selectItem(item: StalkerVodSource) {
this.closeInlinePlayer();
const filterType = this.selectedFilterType();
const hasEmbeddedSeries = (item.series?.length ?? 0) > 0;
const needsSeriesFetch =
@@ -338,6 +356,7 @@ export class StalkerSearchComponent {
default:
break;
}
this.syncPlaybackOwner(this.playbackOwnerKey());
}
onVodPlay(item: VodDetailsItem): void {
@@ -430,6 +449,12 @@ export class StalkerSearchComponent {
);
}
private syncPlaybackOwner(ownerKey: string): void {
if (ownerKey === this.currentPlaybackOwnerKey) return;
this.currentPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
}
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails(),
@@ -0,0 +1,120 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access';
import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter';
import { createStalkerEpisodePlaybackSessionKey } from './stalker-episode-playback-session-key';
const episodeState = (
episode: Partial<StalkerMappedEpisode>,
seasonKey = 'season|1'
) => ({
seasonKey,
seasonNumber: 1,
episodeNumber: 2,
episode: {
id: 'synthesized-32-bit-id',
title: 'Episode 2',
episode_num: 2,
season: 1,
...episode,
} as StalkerMappedEpisode,
previous: null,
next: null,
});
describe('createStalkerEpisodePlaybackSessionKey', () => {
it.each([
STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
])('uses the complete source command for %s episodes', (seriesMode) => {
const state = episodeState({ originalCmd: 'ffrt4://cmd|full:2' });
const composite = JSON.stringify([
seriesMode,
'parent|series',
state.seasonKey,
state.seasonNumber,
state.episodeNumber,
'ffrt4://cmd|full:2',
]);
expect(
createStalkerEpisodePlaybackSessionKey({
sourceId: 'playlist|source',
parentSeriesId: 'parent|series',
seriesMode,
episodeState: state,
})
).toBe(
createPlaybackSessionKey({
kind: 'episode',
sourceId: 'playlist|source',
contentId: composite,
seriesId: 'parent|series',
seasonNumber: 1,
episodeNumber: 2,
})
);
});
it('uses the complete provider episode id for lazy VOD episodes', () => {
const state = episodeState({ originalId: 'provider|episode:full' });
const key = createStalkerEpisodePlaybackSessionKey({
sourceId: 'playlist',
parentSeriesId: '50001',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
episodeState: state,
});
expect(key).toContain('provider|episode:full');
expect(key).not.toContain('synthesized-32-bit-id');
});
it('separates parent, mode, season, command, and episode identity without delimiter collisions', () => {
const base = {
sourceId: 'playlist',
parentSeriesId: 'parent|a',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: episodeState({ originalCmd: 'cmd|a' }),
} as const;
const variants = [
{ ...base, parentSeriesId: 'parent|b' },
{
...base,
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
},
{
...base,
episodeState: episodeState(
{ originalCmd: 'cmd|a' },
'season|2'
),
},
{ ...base, episodeState: episodeState({ originalCmd: 'cmd|b' }) },
{
...base,
episodeState: {
...base.episodeState,
episodeNumber: 3,
},
},
];
const baseKey = createStalkerEpisodePlaybackSessionKey(base);
expect(
variants.map(createStalkerEpisodePlaybackSessionKey)
).not.toContain(baseKey);
});
it.each([episodeState({}), episodeState({ originalCmd: '' })])(
'fails closed when original episode identity is absent',
(state) => {
expect(
createStalkerEpisodePlaybackSessionKey({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: state,
})
).toBe('');
}
);
});
@@ -0,0 +1,184 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import {
normalizeStalkerEntityId,
type StalkerMappedEpisode,
} from '@iptvnator/portal/stalker/data-access';
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback';
import {
STALKER_SERIES_DOWNLOAD_MODES,
type StalkerSeriesDownloadMode,
} from './stalker-series-download.adapter';
export interface StalkerEpisodePlaybackSessionKeyOptions {
readonly sourceId: string | null | undefined;
readonly parentSeriesId: unknown;
readonly seriesMode: StalkerSeriesDownloadMode;
readonly episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null;
}
export interface StalkerEpisodePlaybackSessionIdentity {
readonly sourceId: string;
readonly parentSeriesId: string;
readonly seriesMode: StalkerSeriesDownloadMode;
readonly originalEpisodeIdentity: string | number;
readonly seasonKey: string;
readonly seasonNumber: number;
readonly episodeNumber: number;
readonly episodeTrackingId: string | number;
readonly sessionKey: string;
}
interface StalkerEpisodeStateLookupOptions {
readonly episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>;
}
function present(value: unknown): value is string | number {
return (
(typeof value === 'string' && value.trim().length > 0) ||
(typeof value === 'number' && Number.isFinite(value))
);
}
export function captureStalkerEpisodePlaybackSessionIdentity({
sourceId,
parentSeriesId,
seriesMode,
episodeState,
}: StalkerEpisodePlaybackSessionKeyOptions): StalkerEpisodePlaybackSessionIdentity | null {
const normalizedSourceId = sourceId?.trim() ?? '';
const seriesId = normalizeStalkerEntityId(parentSeriesId);
if (!normalizedSourceId || !seriesId || !episodeState) return null;
const { episodeNumber, seasonKey, seasonNumber } = episodeState;
const episode = episodeState.episode as StalkerMappedEpisode;
const originalIdentity =
seriesMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod
? episode.originalId
: episode.originalCmd;
if (
!present(originalIdentity) ||
!seasonKey.trim() ||
!Number.isSafeInteger(seasonNumber) ||
seasonNumber < 0 ||
!Number.isSafeInteger(episodeNumber) ||
episodeNumber < 1
) {
return null;
}
const contentId = JSON.stringify([
seriesMode,
seriesId,
seasonKey,
seasonNumber,
episodeNumber,
originalIdentity,
]);
const sessionKey = createPlaybackSessionKey({
kind: 'episode',
sourceId: normalizedSourceId,
contentId,
seriesId,
seasonNumber,
episodeNumber,
});
return Object.freeze({
sourceId: normalizedSourceId,
parentSeriesId: seriesId,
seriesMode,
originalEpisodeIdentity: originalIdentity,
seasonKey,
seasonNumber,
episodeNumber,
episodeTrackingId: episodeState.episode.id,
sessionKey,
});
}
export function createStalkerEpisodePlaybackSessionKey(
options: StalkerEpisodePlaybackSessionKeyOptions
): string {
return (
captureStalkerEpisodePlaybackSessionIdentity(options)?.sessionKey ?? ''
);
}
export function resolveSelectedStalkerEpisodeState({
episodesBySeason,
episode,
}: StalkerEpisodeStateLookupOptions & {
readonly episode: XtreamSerieEpisode;
}): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
const exactState = findStalkerEpisodeState(
episodesBySeason,
(candidate) => candidate === episode
);
if (exactState) return exactState;
const selected = episode as StalkerMappedEpisode;
const originalIdentity = selected.originalId ?? selected.originalCmd;
const selectedSeasonNumber = Number(episode.season) || 0;
if (!present(originalIdentity)) return null;
return findStalkerEpisodeState(episodesBySeason, (candidate, state) => {
const mapped = candidate as StalkerMappedEpisode;
return (
(mapped.originalId ?? mapped.originalCmd) === originalIdentity &&
(!selectedSeasonNumber ||
state.seasonNumber === selectedSeasonNumber) &&
state.episodeNumber === Number(episode.episode_num)
);
});
}
export function resolveStalkerEpisodeStateByIdentity({
episodesBySeason,
identity,
}: StalkerEpisodeStateLookupOptions & {
readonly identity: StalkerEpisodePlaybackSessionIdentity;
}): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
return findStalkerEpisodeState(
{ [identity.seasonKey]: episodesBySeason[identity.seasonKey] ?? [] },
(candidate, state) => {
const mapped = candidate as StalkerMappedEpisode;
const originalIdentity =
identity.seriesMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod
? mapped.originalId
: mapped.originalCmd;
return (
originalIdentity === identity.originalEpisodeIdentity &&
state.seasonNumber === identity.seasonNumber &&
state.episodeNumber === identity.episodeNumber
);
}
);
}
function findStalkerEpisodeState(
episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>,
matches: (
episode: XtreamSerieEpisode,
state: SeriesPlaybackEpisodeState<XtreamSerieEpisode>
) => boolean
): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
for (const [seasonKey, episodes] of Object.entries(episodesBySeason)) {
for (
let episodeIndex = 0;
episodeIndex < episodes.length;
episodeIndex++
) {
const episode = episodes[episodeIndex];
const state = {
seasonKey,
seasonNumber: Number(episode.season) || Number(seasonKey) || 0,
episodeNumber: Number(episode.episode_num) || episodeIndex + 1,
episode,
previous: episodes[episodeIndex - 1] ?? null,
next: episodes[episodeIndex + 1] ?? null,
};
if (matches(episode, state)) return state;
}
}
return null;
}
@@ -167,6 +167,7 @@
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playbackSessionKey]="playbackSessionKey()"
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesTitle]="serial.info.name"
@@ -53,6 +53,7 @@ class StubSeasonContainerComponent {
template: '',
})
class StubPortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
@@ -771,17 +772,17 @@ describe('StalkerSeriesViewComponent', () => {
}),
})
);
expect(inlinePlayer.episodeMetadata()).toEqual({
label: 'S01E01',
title: 'Pilot',
seasonNumber: 1,
episodeNumber: 1,
});
// Prettier expands these compact fixtures enough to breach the spec's
// hard 1,200-line lint limit.
// prettier-ignore
expect(inlinePlayer.episodeMetadata()).toEqual({ label: 'S01E01', title: 'Pilot', seasonNumber: 1, episodeNumber: 1 });
expect(inlinePlayer.seriesNavigation()).toEqual({
canPrevious: false,
canNext: true,
autoplayEnabled: true,
});
const firstEpisodeKey = inlinePlayer.playbackSessionKey();
expect(firstEpisodeKey).not.toBe('');
inlinePlayer.playbackEnded.emit();
await fixture.whenStable();
@@ -806,6 +807,7 @@ describe('StalkerSeriesViewComponent', () => {
canNext: false,
autoplayEnabled: true,
});
expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey);
inlinePlayer.playbackEnded.emit();
await fixture.whenStable();
@@ -61,7 +61,6 @@ import {
getSeriesPlaybackNavigation,
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
resolveSeriesPlaybackEpisodeState,
type SeriesPlaybackEpisodeState,
type UpNextRailItem,
} from '@iptvnator/ui/playback';
@@ -85,6 +84,12 @@ import {
createStalkerSeriesDownloadAdapter,
STALKER_SERIES_DOWNLOAD_MODES,
} from './stalker-series-download.adapter';
import {
captureStalkerEpisodePlaybackSessionIdentity,
resolveSelectedStalkerEpisodeState,
resolveStalkerEpisodeStateByIdentity,
type StalkerEpisodePlaybackSessionIdentity,
} from './stalker-episode-playback-session-key';
interface SeriesPositionContext {
readonly generation: number;
@@ -93,46 +98,10 @@ interface SeriesPositionContext {
readonly mutationKey: string;
}
function snapshotTextList(value: string | undefined): string[] | undefined {
const entries = value
?.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
return entries?.length ? entries : undefined;
}
function snapshotNumber(
value: string | number | undefined
): number | undefined {
if (value === undefined || String(value).trim() === '') {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function snapshotYear(value: string | undefined): number | undefined {
const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/);
return match ? Number(match[1]) : undefined;
}
function snapshotPeople(
enriched: TmdbEnrichedCastMember[] | undefined,
fallback: string | undefined
) {
return enriched?.length
? enriched.map((person) => ({
name: person.name,
role: person.character,
profileUrl: person.profileUrl ?? undefined,
tmdbPersonId: person.tmdbPersonId,
}))
: snapshotTextList(fallback)?.map((name) => ({ name }));
}
function positiveCoordinate(value: number | string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
interface StalkerSeriesPlaybackRequestContext {
readonly generation: number;
readonly usesEmbeddedPlayer: boolean;
readonly identity: StalkerEpisodePlaybackSessionIdentity | null;
}
/**
@@ -215,6 +184,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
>();
private readonly seriesPositionReloadKeys = new Set<string>();
private seriesPositionsLoadGeneration = 0;
private seriesPlaybackRequestGeneration = 0;
private currentSeriesPlaybackOwnerKey = '';
private readonly inlinePlaybackEpisodeState =
signal<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(null);
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
@@ -308,6 +281,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
constructor() {
effect(() => {
const ownerKey = this.seriesPlaybackOwnerKey();
untracked(() => this.syncSeriesPlaybackOwner(ownerKey));
});
// TMDB season fetch, keyed on (tmdb_id, selected season). With season
// tabs the first seasonSelected fires immediately when seasons load —
// usually BEFORE the async show-level TMDB enrichment has written
@@ -426,9 +404,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
) {
return;
}
this.episodePlaybackPositions.set(
reconciled.positionsByTrackingId
);
this.episodePlaybackPositions.set(reconciled.positionsByTrackingId);
this.legacyPositionByTrackingId.set(
reconciled.legacyPositionByTrackingId
);
@@ -488,15 +464,14 @@ export class StalkerSeriesViewComponent implements OnDestroy {
// The facade/runtime already saved this row. Repeat the
// idempotent upsert because only this view owns the
// scoped-to-legacy cleanup mapping.
void this.persistSeriesPosition(
playlistId,
data
).catch((error: unknown) => {
this.logger.error(
'Failed to persist runtime series position',
error
);
});
void this.persistSeriesPosition(playlistId, data).catch(
(error: unknown) => {
this.logger.error(
'Failed to persist runtime series position',
error
);
}
);
}
) ?? null;
}
@@ -534,6 +509,22 @@ export class StalkerSeriesViewComponent implements OnDestroy {
youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer)
);
readonly seriesMode = computed(() =>
this.isVodSeries()
? STALKER_SERIES_DOWNLOAD_MODES.LazyVod
: this.vodWithSeries()
? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod
: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries
);
private readonly seriesPlaybackOwnerKey = computed(() => {
const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? '';
const parentSeriesId = normalizeStalkerEntityId(this.displayItem()?.id);
return sourceId && parentSeriesId
? JSON.stringify([sourceId, parentSeriesId, this.seriesMode()])
: '';
});
readonly episodeDownloadAdapter = computed(() => {
const playlist = this.stalkerStore.currentPlaylist();
const item = this.displayItem();
@@ -545,11 +536,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.translateService.defaultLang ||
'en',
seriesId: this.toSeriesId(item?.id ?? 0),
seriesMode: this.isVodSeries()
? STALKER_SERIES_DOWNLOAD_MODES.LazyVod
: this.vodWithSeries()
? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod
: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
seriesMode: this.seriesMode(),
resolveUrl: (command, episodeNumber) =>
this.stalkerStore.fetchLinkToPlay(
playlist?.portalUrl ?? '',
@@ -591,10 +578,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
vodSeriesSeasons: this.vodSeriesSeasons(),
});
});
readonly inlineEpisodeState =
computed<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(() =>
this.getInlineEpisodeState()
);
readonly inlineEpisodeState = computed(() =>
this.inlinePlaybackEpisodeState()
);
readonly playbackSessionKey = signal('');
readonly inlineEpisodeMetadata = computed(() =>
getSeriesEpisodeMetadata(this.inlineEpisodeState())
);
@@ -769,26 +756,32 @@ export class StalkerSeriesViewComponent implements OnDestroy {
*/
onEpisodeClicked(episode: XtreamSerieEpisode) {
const mappedEpisode = episode as StalkerMappedEpisode;
const item = this.displayItem();
const episodeState = resolveSelectedStalkerEpisodeState({
episodesBySeason: this.mappedSeasons(),
episode,
});
if (!item || !episodeState) return;
this.syncSeriesPlaybackOwner(this.seriesPlaybackOwnerKey());
if (mappedEpisode.custom_sid === 'vod-series') {
// It's a VOD series episode (is_series=1 mode)
// Use originalId for playback URL, but use generated id for tracking
this.playVodSeriesEpisode({
originalId: mappedEpisode.originalId, // For constructing playback URL
trackingId: episode.id, // The generated unique ID for position tracking
name: episode.title,
series_number: episode.episode_num,
});
} else {
// Regular series or vclub mode (VOD with embedded series array)
// Use originalCmd for playback, episode.id for tracking
const trackingId = Number(episode.id);
this.playEpisodeClicked(
episode.episode_num,
mappedEpisode.originalCmd,
trackingId
);
}
const isLazyVod = mappedEpisode.custom_sid === 'vod-series';
const command = isLazyVod
? `/media/file_${mappedEpisode.originalId ?? ''}.mpg`
: mappedEpisode.originalCmd;
const title = isLazyVod
? `${item.info.name} - ${episode.title || `Episode ${episodeState.episodeNumber}`}`
: item.info.name;
const trackingId = Number(episode.id);
const startTime =
this.episodePlaybackPositions().get(trackingId)?.positionSeconds;
void this.startPlayback(
command,
title,
item.info.movie_image,
episodeState,
startTime
);
}
async playQuickStartEpisode(): Promise<void> {
@@ -807,69 +800,6 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
}
/**
* Play episode - handles regular series and vclub mode
*/
playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) {
const item = this.displayItem();
if (!item) return;
this.logger.debug('playEpisodeClicked', {
episodeNum,
cmd,
trackingId,
seriesId: item.id,
});
const position = trackingId
? this.episodePlaybackPositions().get(trackingId)
: undefined;
const startTime = position?.positionSeconds;
void this.startPlayback(
cmd,
item.info.name,
item.info.movie_image,
episodeNum,
trackingId,
startTime
);
}
/**
* Play VOD series episode
*/
playVodSeriesEpisode(episode: {
originalId?: string;
trackingId: string;
name: string;
series_number: number;
}) {
const item = this.displayItem();
if (!item) return;
// Use originalId for playback URL (this is what the Stalker API expects)
const cmd = `/media/file_${episode.originalId ?? ''}.mpg`;
const episodeName = episode.name || `Episode ${episode.series_number}`;
// Use trackingId (generated unique ID) for playback position tracking
const trackingId = Number(episode.trackingId);
this.logger.debug('playVodSeriesEpisode', {
originalId: episode.originalId,
trackingId,
seriesId: item.id,
episodeName,
});
const position = this.episodePlaybackPositions().get(trackingId);
const startTime = position?.positionSeconds;
void this.startPlayback(
cmd,
`${item.info.name} - ${episodeName}`,
item.info.movie_image,
episode.series_number,
trackingId,
startTime
);
}
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
@@ -907,7 +837,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
closeInlinePlayer(): void {
this.seriesPlaybackRequestGeneration += 1;
this.inlinePlayback.set(null);
this.inlinePlaybackEpisodeState.set(null);
this.playbackSessionKey.set('');
this.lastSaveTime = 0;
}
@@ -984,13 +917,27 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
private async startPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
cmd: string | undefined,
title: string | undefined,
thumbnail: string | undefined,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode>,
startTime?: number
): Promise<void> {
const generation = ++this.seriesPlaybackRequestGeneration;
const episodeNum = episodeState.episodeNumber;
const episodeId = Number(episodeState.episode.id);
const request: StalkerSeriesPlaybackRequestContext = {
generation,
usesEmbeddedPlayer: this.portalPlayer.isEmbeddedPlayer(),
identity: captureStalkerEpisodePlaybackSessionIdentity({
sourceId: this.stalkerStore.currentPlaylist()?._id,
parentSeriesId: this.displayItem()?.id,
seriesMode: this.seriesMode(),
episodeState,
}),
};
if (request.usesEmbeddedPlayer && !request.identity) return;
try {
const playback = await this.stalkerStore.resolveVodPlayback(
cmd,
@@ -1000,14 +947,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
episodeId,
startTime
);
const episodeState =
episodeId === undefined
? null
: resolveSeriesPlaybackEpisodeState({
episodesBySeason: this.mappedSeasons(),
currentEpisodeId: episodeId,
fallbackEpisodeNumber: episodeNum,
});
if (!this.isPlaybackRequestCurrent(request)) return;
const resolvedPlayback =
episodeState && playback.contentInfo?.contentType === 'episode'
? {
@@ -1021,14 +962,19 @@ export class StalkerSeriesViewComponent implements OnDestroy {
: playback;
this.lastSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(resolvedPlayback);
if (request.usesEmbeddedPlayer && request.identity) {
this.setInlinePlayback(
resolvedPlayback,
request.identity.sessionKey,
episodeState
);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true);
} catch (error) {
if (!this.isPlaybackRequestCurrent(request)) return;
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
@@ -1042,25 +988,47 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
}
private getInlineEpisodeState(): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
const playback = this.inlinePlayback();
const currentEpisodeId = playback?.contentInfo?.contentXtreamId;
if (
playback?.contentInfo?.contentType !== 'episode' ||
currentEpisodeId === undefined
) {
return null;
private isPlaybackRequestCurrent(
request: StalkerSeriesPlaybackRequestContext
): boolean {
if (request.generation !== this.seriesPlaybackRequestGeneration) {
return false;
}
if (!request.identity) return true;
return resolveSeriesPlaybackEpisodeState({
const identity = request.identity;
const episodeState = resolveStalkerEpisodeStateByIdentity({
episodesBySeason: this.mappedSeasons(),
currentEpisodeId,
identity,
});
const currentIdentity = captureStalkerEpisodePlaybackSessionIdentity({
sourceId: this.stalkerStore.currentPlaylist()?._id,
parentSeriesId: this.displayItem()?.id,
seriesMode: this.seriesMode(),
episodeState,
});
return currentIdentity?.sessionKey === identity.sessionKey;
}
ngOnDestroy(): void {
this.unsubscribePositionUpdates?.();
this.closeInlinePlayer();
}
private setInlinePlayback(
playback: ResolvedPortalPlayback,
sessionKey: string,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
): void {
this.playbackSessionKey.set(sessionKey);
this.inlinePlaybackEpisodeState.set(episodeState);
this.inlinePlayback.set(playback);
}
private syncSeriesPlaybackOwner(ownerKey: string): void {
if (ownerKey === this.currentSeriesPlaybackOwnerKey) return;
this.currentSeriesPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
}
async handlePlaybackToggleRequested(
@@ -1072,17 +1040,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
if (request.nextPosition) {
await this.persistSeriesPosition(
playlistId,
request.nextPosition
);
await this.persistSeriesPosition(playlistId, request.nextPosition);
return;
}
await this.clearSeriesPosition(
playlistId,
request.contentXtreamId
);
await this.clearSeriesPosition(playlistId, request.contentXtreamId);
}
handlePlaybackToggleRequestedFromUi(
@@ -1104,9 +1066,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const generation = ++this.seriesPositionsLoadGeneration;
this.trackPendingSeriesPositionLoad(context, generation);
try {
await this.waitForSeriesPositionMutations(
context.mutationKey
);
await this.waitForSeriesPositionMutations(context.mutationKey);
if (
generation !== this.seriesPositionsLoadGeneration ||
@@ -1155,16 +1115,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return (
activeContext === context &&
activeContext.generation === context.generation &&
this.stalkerStore.currentPlaylist()?._id ===
context.playlistId &&
this.stalkerStore.currentPlaylist()?._id === context.playlistId &&
this.toSeriesId(this.displayItem()?.id ?? 0) ===
context.seriesXtreamId
);
}
private waitForSeriesPositionMutations(
mutationKey: string
): Promise<void> {
private waitForSeriesPositionMutations(mutationKey: string): Promise<void> {
return (
this.seriesPositionMutationQueues.get(mutationKey) ??
Promise.resolve()
@@ -1176,8 +1133,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
generation: number
): void {
const generations =
this.pendingSeriesPositionLoads.get(context) ??
new Set<number>();
this.pendingSeriesPositionLoads.get(context) ?? new Set<number>();
generations.add(generation);
this.pendingSeriesPositionLoads.set(context, generations);
}
@@ -1186,8 +1142,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
context: SeriesPositionContext,
generation: number
): void {
const generations =
this.pendingSeriesPositionLoads.get(context);
const generations = this.pendingSeriesPositionLoads.get(context);
generations?.delete(generation);
if (generations?.size === 0) {
this.pendingSeriesPositionLoads.delete(context);
@@ -1220,30 +1175,20 @@ export class StalkerSeriesViewComponent implements OnDestroy {
() => undefined,
() => undefined
);
this.seriesPositionMutationQueues.set(
context.mutationKey,
barrier
);
this.seriesPositionMutationQueues.set(context.mutationKey, barrier);
void barrier.then(() => {
if (
this.seriesPositionMutationQueues.get(
context.mutationKey
) === barrier
this.seriesPositionMutationQueues.get(context.mutationKey) ===
barrier
) {
this.seriesPositionMutationQueues.delete(
context.mutationKey
);
this.reloadSeriesPositionsAfterMutations(
context.mutationKey
);
this.seriesPositionMutationQueues.delete(context.mutationKey);
this.reloadSeriesPositionsAfterMutations(context.mutationKey);
}
});
return result;
}
private reloadSeriesPositionsAfterMutations(
mutationKey: string
): void {
private reloadSeriesPositionsAfterMutations(mutationKey: string): void {
if (!this.seriesPositionReloadKeys.delete(mutationKey)) {
return;
}
@@ -1286,10 +1231,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
if (!context) {
return Promise.resolve();
}
const legacyPosition =
this.legacyPositionByTrackingId().get(
position.contentXtreamId
);
const legacyPosition = this.legacyPositionByTrackingId().get(
position.contentXtreamId
);
return this.enqueueSeriesPositionMutation(context, async () => {
let clearedLegacy: boolean;
try {
@@ -1301,8 +1245,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
});
} catch (error) {
if (
error instanceof
StalkerSeriesPositionPartialSaveError &&
error instanceof StalkerSeriesPositionPartialSaveError &&
this.isSeriesPositionContextActive(context)
) {
this.publishSavedSeriesPosition(
@@ -1329,14 +1272,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
legacyPosition: PlaybackPositionData | undefined,
clearedLegacy: boolean
): void {
const removedTrackingIds = new Set([
position.contentXtreamId,
]);
const removedTrackingIds = new Set([position.contentXtreamId]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(
this.legacyPositionByTrackingId()
);
const legacyPositions = new Map(this.legacyPositionByTrackingId());
legacyPositions.delete(position.contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
@@ -1344,9 +1283,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.rawSeriesPositions.set([
...this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(
candidate.contentXtreamId
)
!removedTrackingIds.has(candidate.contentXtreamId)
),
position,
]);
@@ -1361,14 +1298,15 @@ export class StalkerSeriesViewComponent implements OnDestroy {
if (!context) {
return Promise.resolve();
}
const position =
this.episodePlaybackPositions().get(contentXtreamId) ?? {
contentXtreamId,
contentType: 'episode',
positionSeconds: 0,
playlistId,
seriesXtreamId: context.seriesXtreamId,
};
const position = this.episodePlaybackPositions().get(
contentXtreamId
) ?? {
contentXtreamId,
contentType: 'episode',
positionSeconds: 0,
playlistId,
seriesXtreamId: context.seriesXtreamId,
};
const legacyPosition =
this.legacyPositionByTrackingId().get(contentXtreamId);
return this.enqueueSeriesPositionMutation(context, async () => {
@@ -1397,9 +1335,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const removedTrackingIds = new Set([contentXtreamId]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(
this.legacyPositionByTrackingId()
);
const legacyPositions = new Map(this.legacyPositionByTrackingId());
legacyPositions.delete(contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
@@ -1407,9 +1343,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.rawSeriesPositions.set(
this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(
candidate.contentXtreamId
)
!removedTrackingIds.has(candidate.contentXtreamId)
)
);
this.removeEpisodePlaybackPosition(contentXtreamId);
@@ -0,0 +1,479 @@
import { EMPTY, of } from 'rxjs';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
StalkerStore,
type StalkerMappedEpisode,
type StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { TmdbEnrichmentService } from '@iptvnator/services';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter';
import { createStalkerEpisodePlaybackSessionKey } from './stalker-episode-playback-session-key';
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe('StalkerSeriesViewComponent playback session key', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const selectedContentType = signal<'series' | 'vod'>('series');
const selectedItem = signal<StalkerVodSource | null>(null);
const serialSeasons = signal<unknown[]>([]);
const vodSeasons = signal<unknown[]>([]);
const resolveVodPlayback = jest.fn();
const currentPlaylist = signal({
_id: 'stalker|playlist',
title: 'Portal',
portalUrl: 'https://stalker.example',
macAddress: '00:1A:79:12:34:56',
});
beforeEach(async () => {
selectedContentType.set('series');
selectedItem.set({
id: 'series|parent',
info: { name: 'Series', movie_image: 'poster.jpg' },
});
serialSeasons.set([
{
id: 'regular-season',
name: 'Season 1',
cmd: 'ffrt4://regular|full-command',
series: [1, 2],
},
]);
vodSeasons.set([]);
resolveVodPlayback
.mockReset()
.mockImplementation(
async (
_command: string,
title: string,
thumbnail: string,
_episodeNumber: number,
episodeId: number
) => ({
streamUrl: 'https://resolved.example/temporary.mpg',
title,
thumbnail,
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: episodeId,
contentType: 'episode',
seriesXtreamId: 999,
},
})
);
await TestBed.configureTestingModule({
imports: [StalkerSeriesViewComponent],
providers: [
{
provide: StalkerStore,
useValue: {
selectedItem,
selectedContentType,
currentPlaylist,
getSerialSeasonsResource: () => serialSeasons(),
getVodSeriesSeasonsResource: () => vodSeasons(),
isVodSeriesSeasonsLoading: signal(false),
isSerialSeasonsLoading: signal(false),
fetchVodSeriesEpisodes: jest.fn(),
resolveVodPlayback,
fetchLinkToPlay: jest.fn(),
clearSelectedItem: jest.fn(),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: signal(null) },
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions: jest
.fn()
.mockResolvedValue([]),
savePlaybackPosition: jest.fn(),
clearPlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openResolvedPlayback: jest.fn(),
},
},
{ provide: Router, useValue: { navigateByUrl: jest.fn() } },
{
provide: TmdbEnrichmentService,
useValue: {
isEnabled: () => false,
getSeason: jest.fn(),
getSeasonEpisodes: jest.fn(),
},
},
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
currentLang: 'en',
defaultLang: 'en',
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
},
},
],
})
.overrideComponent(StalkerSeriesViewComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(StalkerSeriesViewComponent);
});
afterEach(() => fixture.destroy());
async function playFirstEpisode(): Promise<StalkerMappedEpisode> {
fixture.detectChanges();
await fixture.whenStable();
const episode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
fixture.componentInstance.onEpisodeClicked(episode);
await fixture.whenStable();
return episode;
}
function setLazySeries(parentId: string, episodeId: string): void {
selectedContentType.set('vod');
selectedItem.set({
id: parentId,
is_series: true,
info: { name: 'Lazy series', movie_image: 'poster.jpg' },
});
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'lazy-season',
video_id: parentId,
season_number: '2',
name: 'Season 2',
episodes: [
{
id: episodeId,
series_number: 3,
name: 'Replacement episode',
},
],
isLoading: false,
isExpanded: false,
},
]);
}
it.each([
STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
])('owns a collision-safe key for %s playback', async (expectedMode) => {
if (expectedMode === STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod) {
fixture.componentRef.setInput('vodWithSeries', {
id: 'series|parent',
cmd: 'ffrt4://embedded|full-command',
series: [1, 2],
info: { name: 'Embedded series', movie_image: 'poster.jpg' },
});
} else if (expectedMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod) {
selectedContentType.set('vod');
selectedItem.set({
id: 'series|parent',
is_series: true,
info: { name: 'Lazy series', movie_image: 'poster.jpg' },
});
vodSeasons.set([]);
fixture.detectChanges();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season|provider',
video_id: 'series|parent',
season_number: '1',
name: 'Season | One',
episodes: [
{
id: 'episode|provider:full',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
]);
}
const episode = await playFirstEpisode();
const key = fixture.componentInstance.playbackSessionKey();
expect(fixture.componentInstance.seriesMode()).toBe(expectedMode);
expect(key).not.toBe('');
expect(key).not.toContain(String(episode.id));
fixture.componentInstance.inlinePlayback.set({
streamUrl: 'https://alternative.example/replacement.mkv',
title: 'Replacement payload',
contentInfo: {
playlistId: 'alternative-playlist',
contentXtreamId: 987654321,
contentType: 'episode',
seriesXtreamId: 123,
seasonNumber: 9,
episodeNumber: 9,
},
});
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
});
it('changes when the original full episode command changes', async () => {
await playFirstEpisode();
const firstKey = fixture.componentInstance.playbackSessionKey();
serialSeasons.set([
{
id: 'replacement-season',
name: 'Season 1',
cmd: 'ffrt4://different|full-command',
series: [1],
},
]);
await playFirstEpisode();
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
firstKey
);
});
it('selects the exact clicked episode when distinct provider commands collide in the tracking hash', async () => {
serialSeasons.set([
{
id: 'collision-a',
name: 'Season A',
cmd: 'collision-cmd-1000',
series: [1],
},
{
id: 'collision-b',
name: 'Season B',
cmd: 'collision-cmd-425721',
series: [1],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const [episodeA, episodeB] = Object.values(
fixture.componentInstance.mappedSeasons()
).map((episodes) => episodes[0] as StalkerMappedEpisode);
expect(episodeA.id).toBe('1511026353');
expect(episodeB.id).toBe(episodeA.id);
fixture.componentInstance.onEpisodeClicked(episodeB);
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'collision-cmd-425721',
'Series',
'poster.jpg',
1,
1511026353,
undefined
);
const expectedBKey = createStalkerEpisodePlaybackSessionKey({
sourceId: 'stalker|playlist',
parentSeriesId: 'series|parent',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: {
seasonKey: '2',
seasonNumber: 2,
episodeNumber: 1,
episode: episodeB,
previous: null,
next: null,
},
});
const wrongAKey = createStalkerEpisodePlaybackSessionKey({
sourceId: 'stalker|playlist',
parentSeriesId: 'series|parent',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: {
seasonKey: '1',
seasonNumber: 1,
episodeNumber: 1,
episode: episodeA,
previous: null,
next: null,
},
});
expect(fixture.componentInstance.playbackSessionKey()).toBe(
expectedBKey
);
expect(expectedBKey).not.toBe(wrongAKey);
});
it.each(['parent', 'mode', 'playlist'] as const)(
'clears committed playback when the canonical %s owner changes',
async (transition) => {
await playFirstEpisode();
expect(fixture.componentInstance.inlinePlayback()).not.toBeNull();
if (transition === 'parent') {
selectedItem.set({
...selectedItem()!,
id: 'replacement-parent',
});
} else if (transition === 'mode') {
selectedContentType.set('vod');
selectedItem.set({ ...selectedItem()!, is_series: true });
} else {
currentPlaylist.set({
...currentPlaylist(),
_id: 'replacement-playlist',
});
}
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe('');
}
);
it('preserves committed playback across a same-owner object refresh', async () => {
await playFirstEpisode();
const playback = fixture.componentInstance.inlinePlayback();
const key = fixture.componentInstance.playbackSessionKey();
selectedItem.set({ ...selectedItem()! });
currentPlaylist.set({ ...currentPlaylist() });
serialSeasons.set(
serialSeasons().map((season) => ({
...(season as Record<string, unknown>),
}))
);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
});
it('rejects a completion after its playlist, parent, mode, and episode owner change', async () => {
fixture.detectChanges();
await fixture.whenStable();
const pending = deferred<ResolvedPortalPlayback>();
resolveVodPlayback.mockReturnValueOnce(pending.promise);
const episode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
fixture.componentInstance.onEpisodeClicked(episode);
currentPlaylist.set({
...currentPlaylist(),
_id: 'replacement-playlist',
});
setLazySeries('replacement-parent', 'replacement-provider-episode');
pending.resolve({
streamUrl: 'https://stale.example/old-episode.mpg',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: episode.id,
contentType: 'episode',
},
});
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe('');
});
it('keeps the newest episode when owner-scoped requests resolve out of order', async () => {
fixture.detectChanges();
await fixture.whenStable();
const oldRequest = deferred<ResolvedPortalPlayback>();
const newRequest = deferred<ResolvedPortalPlayback>();
resolveVodPlayback
.mockReturnValueOnce(oldRequest.promise)
.mockReturnValueOnce(newRequest.promise);
const oldEpisode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
fixture.componentInstance.onEpisodeClicked(oldEpisode);
currentPlaylist.set({ ...currentPlaylist(), _id: 'new-playlist' });
setLazySeries('new-parent', 'new-provider-episode');
const newEpisode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
fixture.componentInstance.onEpisodeClicked(newEpisode);
const newestPlayback = {
streamUrl: 'https://new.example/episode.mpg',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: newEpisode.id,
contentType: 'episode' as const,
},
};
newRequest.resolve(newestPlayback);
oldRequest.resolve({
streamUrl: 'https://old.example/episode.mpg',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: oldEpisode.id,
contentType: 'episode',
},
});
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.inlinePlayback()?.streamUrl).toBe(
newestPlayback.streamUrl
);
expect(fixture.componentInstance.playbackSessionKey()).not.toBe('');
});
it('preserves a valid mounted episode when original identity is missing', async () => {
await playFirstEpisode();
const playback = fixture.componentInstance.inlinePlayback();
const key = fixture.componentInstance.playbackSessionKey();
serialSeasons.set([
{
id: 'identity-less-season',
name: 'Season 1',
series: [1],
},
]);
await playFirstEpisode();
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
});
});
@@ -80,6 +80,7 @@ function createController() {
return {
controller,
inlinePlayback,
playbackPositions,
selectedVodPosition,
};
@@ -115,4 +116,31 @@ describe('StalkerVodPlaybackController', () => {
expect(selectedVodPosition()?.contentXtreamId).toBe(202);
expect(selectedVodPosition()?.positionSeconds).toBe(20);
});
it('does not mount playback that resolves after the detail closes', async () => {
const { controller, inlinePlayback } = createController();
const pending = createDeferred<{ streamUrl: string }>();
const playback = controller.startVodPlayback(() => pending.promise);
controller.closeInlinePlayer();
pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' });
await playback;
expect(inlinePlayback()).toBeNull();
});
it('keeps the newest VOD request when resolutions finish out of order', async () => {
const { controller, inlinePlayback } = createController();
const older = createDeferred<{ streamUrl: string }>();
const newer = createDeferred<{ streamUrl: string }>();
const olderRequest = controller.startVodPlayback(() => older.promise);
const newerRequest = controller.startVodPlayback(() => newer.promise);
const newestPlayback = { streamUrl: 'https://new.example/movie.mpg' };
newer.resolve(newestPlayback);
older.resolve({ streamUrl: 'https://old.example/movie.mpg' });
await Promise.all([olderRequest, newerRequest]);
expect(inlinePlayback()).toBe(newestPlayback);
});
});
@@ -21,22 +21,30 @@ interface StalkerVodPlaybackControllerConfig {
translateService: TranslateService;
logger: Logger;
playbackErrorLogMessage: string;
playbackOwnerKey?: () => string;
}
export class StalkerVodPlaybackController {
private lastInlineSaveTime = 0;
private loadSelectedVodPositionRequestId = 0;
private playbackRequestId = 0;
constructor(private readonly config: StalkerVodPlaybackControllerConfig) {}
async startVodPlayback(
resolvePlayback: () => Promise<ResolvedPortalPlayback>
): Promise<void> {
const requestId = ++this.playbackRequestId;
const usesEmbeddedPlayer = this.config.portalPlayer.isEmbeddedPlayer();
const playbackOwnerKey = this.config.playbackOwnerKey?.();
try {
const playback = await resolvePlayback();
if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) {
return;
}
this.lastInlineSaveTime = 0;
if (this.config.portalPlayer.isEmbeddedPlayer()) {
if (usesEmbeddedPlayer) {
this.config.inlinePlayback.set(playback);
return;
}
@@ -44,6 +52,9 @@ export class StalkerVodPlaybackController {
this.closeInlinePlayer();
void this.config.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) {
return;
}
this.config.logger.error(
this.config.playbackErrorLogMessage,
error
@@ -115,10 +126,22 @@ export class StalkerVodPlaybackController {
}
closeInlinePlayer(): void {
this.playbackRequestId += 1;
this.config.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
private isPlaybackRequestCurrent(
requestId: number,
playbackOwnerKey: string | undefined
): boolean {
return (
requestId === this.playbackRequestId &&
(this.config.playbackOwnerKey === undefined ||
this.config.playbackOwnerKey() === playbackOwnerKey)
);
}
showCopyNotification(): void {
this.config.snackBar.open(
this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'),
@@ -96,6 +96,7 @@
@if (isEmbeddedPlayer) {
<div class="video-player">
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="streamUrl"
[title]="activePlayback()?.title ?? ''"
[playback]="activePlayback()"
@@ -29,13 +29,21 @@ import {
EpgTimelineComponent,
EpgTimelineSummary,
} from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
import {
type PlaybackFallbackRequest,
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import {
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { GridListComponent } from '@iptvnator/portal/shared/ui';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { PageEvent } from '@angular/material/paginator';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -78,10 +86,11 @@ class StubGridListComponent {
template: '',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly externalFallbackRequested = output<unknown>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
@@ -195,6 +204,7 @@ describe('LiveStreamLayoutComponent', () => {
};
const portalPlayer = {
isEmbeddedPlayer: jest.fn().mockReturnValue(true),
openExternalPlayback: jest.fn(),
};
const settingsStore = {
openStreamOnDoubleClick: signal(false),
@@ -206,6 +216,7 @@ describe('LiveStreamLayoutComponent', () => {
const originalElectron = window.electron;
beforeEach(async () => {
currentPlaylist.set(playlist);
jest.useFakeTimers();
jest.setSystemTime(fixedNow);
settingsStore.resolvedEpgViewMode.set('timeline');
@@ -240,6 +251,7 @@ describe('LiveStreamLayoutComponent', () => {
xtreamUrlService.resolveCatchupUrl.mockClear();
portalPlayer.isEmbeddedPlayer.mockReset();
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
portalPlayer.openExternalPlayback.mockClear();
epgItems.set([]);
currentEpgItem.set(null);
@@ -590,6 +602,58 @@ describe('LiveStreamLayoutComponent', () => {
).not.toBeNull();
});
it('keeps the host-owned live key across URL replacement and changes it with the channel', () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
currentPlaylist.set({ ...playlist, id: 'playlist|one' });
const first = { ...sampleChannel, xtream_id: 101 };
component.playLive(first);
fixture.detectChanges();
const webPlayer = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
const expected = createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist|one',
contentId: 101,
});
expect(webPlayer.playbackSessionKey()).toBe(expected);
xtreamStore.constructStreamUrl.mockReturnValueOnce(
'https://example.com/replaced-timeshift.ts'
);
component.playLive(first);
fixture.detectChanges();
expect(webPlayer.playbackSessionKey()).toBe(expected);
component.playLive({ ...first, xtream_id: 102 });
fixture.detectChanges();
expect(webPlayer.playbackSessionKey()).not.toBe(expected);
});
it('forwards the exact resolved live playback to external fallback', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/fallback.m3u8',
title: 'Channel 101',
isLive: true,
headers: { Authorization: 'Bearer token' },
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 101,
contentType: 'live',
},
};
component.handleExternalFallbackRequest({
player: 'mpv',
playback,
} as PlaybackFallbackRequest);
const [forwardedPlayback, forwardedPlayer] =
portalPlayer.openExternalPlayback.mock.calls[0];
expect(forwardedPlayback).toBe(playback);
expect(forwardedPlayer).toBe('mpv');
});
it('updates live root pagination from the header paginator', () => {
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
@@ -794,17 +858,44 @@ describe('LiveStreamLayoutComponent', () => {
);
});
it('shows the active archive program in the live EPG panel summary', async () => {
const archivedProgram: EpgProgram = {
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1775296800,
stopTimestamp: 1775300400,
it('does not attach a pending catchup result to a newer live owner', async () => {
let resolveCatchup!: (url: string) => void;
xtreamUrlService.resolveCatchupUrl.mockReturnValueOnce(
new Promise((resolve) => {
resolveCatchup = resolve;
})
);
component.playLive(sampleChannel);
const catchup = component.onProgramActivated({
type: 'timeshift',
program: buildArchivedProgram(),
});
const newerChannel = {
...sampleChannel,
xtream_id: 202,
name: 'Channel 202',
};
xtreamStore.constructStreamUrl.mockReturnValueOnce(
'https://example.com/channel-202.ts'
);
component.playLive(newerChannel);
resolveCatchup('https://stale.example/channel-101-timeshift.ts');
await catchup;
expect(component.activePlayback()?.streamUrl).toBe(
'https://example.com/channel-202.ts'
);
expect(component.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist-1',
contentId: 202,
})
);
});
it('shows the active archive program in the live EPG panel summary', async () => {
const archivedProgram = buildArchivedProgram();
epgItems.set([
buildEpgItem(
'archived',
@@ -854,16 +945,7 @@ describe('LiveStreamLayoutComponent', () => {
});
it('returns archive playback to the selected live stream from the panel action', async () => {
const archivedProgram: EpgProgram = {
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1775296800,
stopTimestamp: 1775300400,
};
const archivedProgram = buildArchivedProgram();
currentEpgItem.set(
buildEpgItem(
'current',
@@ -893,7 +975,9 @@ describe('LiveStreamLayoutComponent', () => {
isLive: true,
})
);
expect(timeline.componentInstance.summary()?.title).toBe('Current Show');
expect(timeline.componentInstance.summary()?.title).toBe(
'Current Show'
);
expect(timeline.componentInstance.summaryLabelKey()).toBe(
'EPG.CURRENT_PROGRAM'
);
@@ -901,16 +985,7 @@ describe('LiveStreamLayoutComponent', () => {
});
it('publishes the active archive program in remote-control status', async () => {
const archivedProgram: EpgProgram = {
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1775296800,
stopTimestamp: 1775300400,
};
const archivedProgram = buildArchivedProgram();
currentEpgItem.set(
buildEpgItem(
'current',
@@ -942,16 +1017,7 @@ describe('LiveStreamLayoutComponent', () => {
});
it('passes the active catchup program to the EPG list until live playback resumes', async () => {
const archivedProgram: EpgProgram = {
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1775296800,
stopTimestamp: 1775300400,
};
const archivedProgram = buildArchivedProgram();
epgItems.set([
buildEpgItem(
'1',
@@ -1066,7 +1132,9 @@ describe('LiveStreamLayoutComponent', () => {
const timeline = fixture.debugElement.query(
By.directive(StubEpgTimelineComponent)
);
expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(true);
expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(
true
);
});
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
@@ -1259,6 +1327,15 @@ describe('LiveStreamLayoutComponent', () => {
});
});
const buildArchivedProgram = (): EpgProgram => ({
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
});
function buildEpgItem(
id: string,
title: string,
@@ -70,6 +70,7 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -269,11 +270,20 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
private playbackRequestId = 0;
readonly usesEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly activeLiveItemId = signal<number | null>(null);
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.activeLiveItemId();
return sourceId && contentId
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
: '';
});
readonly activeStreamUrl = computed(
() => this.activePlayback()?.streamUrl ?? ''
);
@@ -435,12 +445,14 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
item: XtreamLiveChannelItem,
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
) {
this.playbackRequestId += 1;
const streamUrl = this.xtreamStore.constructStreamUrl(item);
this.activeCatchupProgram.set(null);
// Keep both root/recently-added playback and same-category replays in
// sync with the category rail. For already-selected channels this is a
// store no-op.
this.selectLiveItemCategory(item);
this.activeLiveItemId.set(item.xtream_id);
this.activePlayback.set({
streamUrl,
title: item.title ?? item.name ?? '',
@@ -541,6 +553,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.playbackRequestId += 1;
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
}
@@ -643,6 +656,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
return;
}
const requestId = ++this.playbackRequestId;
this.activeLiveItemId.set(item.xtream_id);
const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl(
playlist.id,
{
@@ -656,6 +671,13 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
stopTimestamp,
playlist.serverTimezone
);
if (
requestId !== this.playbackRequestId ||
this.xtreamStore.currentPlaylist()?.id !== playlist.id ||
this.activeLiveItemId() !== item.xtream_id
) {
return;
}
this.activeCatchupProgram.set(program);
this.activePlayback.set({
@@ -71,6 +71,8 @@ export class SerialDetailsPlaybackService {
private lastSaveTime = 0;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly inlinePlaybackSessionEpisodeState =
signal<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(null);
readonly episodePlaybackPositions = this.playbackPositionState.positions;
readonly openingEpisodeId = signal<number | null>(null);
readonly activeEpisodeId = signal<number | null>(null);
@@ -221,7 +223,13 @@ export class SerialDetailsPlaybackService {
contentInfo,
};
this.startPlayback(playback);
const episodeState = resolveSeriesPlaybackEpisodeState({
episodesBySeason: selectedItem.episodes,
currentEpisodeId: episode.id,
fallbackSeasonNumber: Number(episode.season),
fallbackEpisodeNumber: Number(episode.episode_num),
});
this.startPlayback(playback, episodeState);
}
playQuickStartEpisode(): void {
@@ -259,6 +267,7 @@ export class SerialDetailsPlaybackService {
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.inlinePlaybackSessionEpisodeState.set(null);
this.lastSaveTime = 0;
}
@@ -350,9 +359,13 @@ export class SerialDetailsPlaybackService {
});
}
private startPlayback(playback: ResolvedPortalPlayback): void {
private startPlayback(
playback: ResolvedPortalPlayback,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
): void {
this.lastSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlaybackSessionEpisodeState.set(episodeState);
this.inlinePlayback.set(playback);
return;
}
@@ -179,6 +179,7 @@
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playbackSessionKey]="playbackSessionKey()"
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesTitle]="item.info.name"
@@ -22,10 +22,11 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { EMPTY, of } from 'rxjs';
import { BehaviorSubject, EMPTY, of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details.component';
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
selector: 'app-season-container',
@@ -54,6 +55,7 @@ class StubSeasonContainerComponent {
template: '',
})
class StubPortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
@@ -108,6 +110,10 @@ describe('SerialDetailsComponent', () => {
let seriesResumeTarget: ReturnType<
typeof signal<SeriesResumeTarget | null>
>;
let routeParams: BehaviorSubject<{
categoryId: string;
serialId: string;
}>;
beforeEach(async () => {
window.history.replaceState({}, '', window.location.href);
@@ -174,6 +180,10 @@ describe('SerialDetailsComponent', () => {
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
seriesResumeTarget = signal<SeriesResumeTarget | null>(null);
routeParams = new BehaviorSubject({
categoryId: '3',
serialId: '103',
});
await TestBed.configureTestingModule({
imports: [SerialDetailsComponent],
@@ -181,10 +191,7 @@ describe('SerialDetailsComponent', () => {
{
provide: ActivatedRoute,
useValue: {
params: of({
categoryId: '3',
serialId: '103',
}),
params: routeParams,
snapshot: {
params: {
categoryId: '3',
@@ -846,6 +853,15 @@ describe('SerialDetailsComponent', () => {
seasonNumber: 1,
episodeNumber: 1,
});
const firstEpisodeKey = createPlaybackSessionKey({
kind: 'episode',
sourceId: 'xtream-1',
contentId: 1001,
seriesId: 103,
seasonNumber: 1,
episodeNumber: 1,
});
expect(inlinePlayer.playbackSessionKey()).toBe(firstEpisodeKey);
expect(inlinePlayer.seriesNavigation()).toEqual({
canPrevious: false,
canNext: true,
@@ -874,6 +890,7 @@ describe('SerialDetailsComponent', () => {
canNext: false,
autoplayEnabled: true,
});
expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey);
inlinePlayer.playbackEnded.emit();
fixture.detectChanges();
@@ -887,4 +904,59 @@ describe('SerialDetailsComponent', () => {
expect.objectContaining({ id: '2001' })
);
});
it('owns the parent identity from the route and ignores replacement playback payloads', async () => {
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
const item = fixture.componentInstance.selectedItem();
const episode = item?.episodes?.['1'][0];
if (!item || !episode) {
throw new Error('Expected the serial fixture and first episode');
}
fixture.componentInstance.playEpisode(episode);
fixture.detectChanges();
const expected = createPlaybackSessionKey({
kind: 'episode',
sourceId: 'xtream-1',
contentId: 1001,
seriesId: 103,
seasonNumber: 1,
episodeNumber: 1,
});
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
fixture.componentInstance.inlinePlayback.set({
streamUrl: 'https://alternative.example/replaced.mkv',
title: 'Alternative payload',
headers: { Authorization: 'Bearer replacement' },
contentInfo: {
playlistId: 'alternative-playlist',
contentXtreamId: 999001,
contentType: 'episode',
seriesXtreamId: 999,
seasonNumber: 9,
episodeNumber: 9,
},
});
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
selectedItem.set({ ...item, series_id: 999 });
routeParams.next({ categoryId: '3', serialId: '104' });
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.playEpisode(episode);
fixture.detectChanges();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'episode',
sourceId: 'xtream-1',
contentId: 1001,
seriesId: 104,
seasonNumber: 1,
episodeNumber: 1,
})
);
});
});
@@ -53,6 +53,7 @@ import {
} from '../tmdb-similar.util';
import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata';
import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter';
import { createSerialPlaybackSessionKey } from './serial-playback-session-key';
@Component({
selector: 'app-serial-details',
@@ -152,6 +153,13 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
readonly quickStartAction = this.playback.quickStartAction;
readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata;
readonly inlineSeriesNavigation = this.playback.inlineSeriesNavigation;
readonly playbackSessionKey = computed(() =>
createSerialPlaybackSessionKey(
this.xtreamStore.currentPlaylist()?.id,
this.routeParams().serialId,
this.playback.inlinePlaybackSessionEpisodeState()
)
);
/** "Up Next" rail entries for the inline player (series only). */
readonly upNextEpisodes = computed<UpNextRailItem[]>(() =>
buildUpNextRailItems({
@@ -0,0 +1,28 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback';
export function createSerialPlaybackSessionKey(
sourceId: string | undefined,
seriesId: number | string | undefined,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
): string {
const normalizedSeriesId = Number(seriesId);
if (
!sourceId ||
!Number.isSafeInteger(normalizedSeriesId) ||
normalizedSeriesId <= 0 ||
!episodeState
) {
return '';
}
return createPlaybackSessionKey({
kind: 'episode',
sourceId,
contentId: episodeState.episode.id,
seriesId: normalizedSeriesId,
seasonNumber: episodeState.seasonNumber,
episodeNumber: episodeState.episodeNumber,
});
}
@@ -27,6 +27,7 @@ import {
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { BehaviorSubject, NEVER, of } from 'rxjs';
import { VodDetailsRouteComponent } from './vod-details-route.component';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
selector: 'app-portal-inline-player',
@@ -34,6 +35,7 @@ import { VodDetailsRouteComponent } from './vod-details-route.component';
template: '<div data-testid="inline-vod-player"></div>',
})
class StubPortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<unknown>(null);
// Multi-source wiring: the real player takes these, so the stub has to
// accept them or the template binding fails to compile.
@@ -369,6 +371,25 @@ describe('VodDetailsRouteComponent fallback actions', () => {
thumbnail: 'https://example.com/catalog-poster.jpg',
})
);
const expectedKey = createPlaybackSessionKey({
kind: 'vod',
sourceId: 'playlist-1',
contentId: 650020,
});
expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey);
fixture.componentInstance.inlinePlayback.set({
streamUrl: 'https://copy.example/movie/9001.mkv',
title: 'Catalog movie',
headers: { 'User-Agent': 'Copy Provider' },
contentInfo: {
playlistId: 'copy-playlist',
contentXtreamId: 9001,
contentType: 'vod',
},
});
fixture.detectChanges();
expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey);
expect(
host.querySelector('app-portal-detail-shell')?.classList
).toContain('shell-host--watch');
@@ -581,6 +602,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
selectedItem.set(richItem());
fixture.detectChanges();
expect(fixture.componentInstance.providerOnly()).toBe(true);
const firstSessionKey = fixture.componentInstance.playbackSessionKey();
window.history.replaceState({}, '', window.location.href);
selectedItem.set({
@@ -594,6 +616,16 @@ describe('VodDetailsRouteComponent fallback actions', () => {
fixture.detectChanges();
expect(fixture.componentInstance.providerOnly()).toBe(false);
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
firstSessionKey
);
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'playlist-1',
contentId: 650021,
})
);
});
it('plays a rich downloaded movie locally without a usable provider source', async () => {
@@ -254,6 +254,7 @@
@if (playableVodItem()) {
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playbackSessionKey]="playbackSessionKey()"
[playback]="playback"
[alternativeSources]="multiSource.alternatives()"
[allSources]="multiSource.sources()"
@@ -72,6 +72,7 @@ import { VodDetailsDownloadsService } from './vod-details-downloads.service';
import { VodDetailsSimilarService } from './vod-details-similar.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
type XtreamVodIdentityItem = XtreamVodDetails & {
readonly id?: number | string;
@@ -162,6 +163,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly isFavorite = this.xtreamStore.isFavorite;
readonly selectedVodId = computed(() => Number(this.routeParams().vodId));
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.selectedVodId();
return sourceId && Number.isFinite(contentId) && contentId > 0
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
readonly providerOnly = computed(() => {
this.routeParams();
return isProviderOnlyDetailState(window.history.state);
@@ -21,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
template: '<div data-test-id="stub-web-player-view"></div>',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
@@ -65,12 +66,10 @@ describe('PortalInlinePlayerComponent sources picker', () => {
let component: PortalInlinePlayerComponentInstance;
beforeAll(async () => {
({ PortalInlinePlayerComponent } = await import(
'./portal-inline-player.component'
));
({ WebPlayerViewComponent } = await import(
'../web-player-view/web-player-view.component'
));
({ PortalInlinePlayerComponent } =
await import('./portal-inline-player.component'));
({ WebPlayerViewComponent } =
await import('../web-player-view/web-player-view.component'));
});
afterEach(() => {
@@ -101,6 +100,7 @@ describe('PortalInlinePlayerComponent sources picker', () => {
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('playbackSessionKey', 'sources-key');
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/movie.mkv',
@@ -18,6 +18,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
template: '<div data-test-id="stub-web-player-view"></div>',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
@@ -41,12 +42,10 @@ describe('PortalInlinePlayerComponent up next rail', () => {
let component: PortalInlinePlayerComponentInstance;
beforeAll(async () => {
({ PortalInlinePlayerComponent } = await import(
'./portal-inline-player.component'
));
({ WebPlayerViewComponent } = await import(
'../web-player-view/web-player-view.component'
));
({ PortalInlinePlayerComponent } =
await import('./portal-inline-player.component'));
({ WebPlayerViewComponent } =
await import('../web-player-view/web-player-view.component'));
});
afterEach(() => {
@@ -77,9 +76,11 @@ describe('PortalInlinePlayerComponent up next rail', () => {
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('playbackSessionKey', 'up-next-key');
}
const railEl = () => fixture.nativeElement.querySelector('app-up-next-rail');
const railEl = () =>
fixture.nativeElement.querySelector('app-up-next-rail');
const seriesPlayback = {
streamUrl: 'https://example.com/episode.mp4',
@@ -84,6 +84,7 @@
></div>
}
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="streamUrl()"
[title]="title()"
[mediaTitle]="playerMediaTitle()"
@@ -3,6 +3,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsStore } from '@iptvnator/services';
import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { PlaybackFallbackRequest } from '@iptvnator/playback/util';
import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component';
jest.unstable_mockModule('video.js', () => ({
@@ -19,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
template: '<div data-test-id="stub-web-player-view"></div>',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
@@ -27,7 +30,7 @@ class StubWebPlayerViewComponent {
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly alternativeSourceRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly playbackEnded = output<void>();
@@ -42,12 +45,10 @@ describe('PortalInlinePlayerComponent', () => {
let component: PortalInlinePlayerComponentInstance;
beforeAll(async () => {
({ PortalInlinePlayerComponent } = await import(
'./portal-inline-player.component'
));
({ WebPlayerViewComponent } = await import(
'../web-player-view/web-player-view.component'
));
({ PortalInlinePlayerComponent } =
await import('./portal-inline-player.component'));
({ WebPlayerViewComponent } =
await import('../web-player-view/web-player-view.component'));
});
beforeEach(async () => {
@@ -66,6 +67,10 @@ describe('PortalInlinePlayerComponent', () => {
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'playbackSessionKey',
'host-owned-session-key'
);
});
afterEach(() => {
@@ -94,13 +99,17 @@ describe('PortalInlinePlayerComponent', () => {
(
component as unknown as {
playbackEnded: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: {
subscribe: (fn: () => void) => void;
};
nextEpisodeRequested: { subscribe: (fn: () => void) => void };
}
).playbackEnded.subscribe(() => events.push('ended'));
(
component as unknown as {
previousEpisodeRequested: { subscribe: (fn: () => void) => void };
previousEpisodeRequested: {
subscribe: (fn: () => void) => void;
};
}
).previousEpisodeRequested.subscribe(() => events.push('previous'));
(
@@ -119,6 +128,7 @@ describe('PortalInlinePlayerComponent', () => {
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
expect(webPlayer.seriesNavigation()).toBe(seriesNavigation);
expect(webPlayer.playbackSessionKey()).toBe('host-owned-session-key');
webPlayer.playbackEnded.emit();
webPlayer.previousEpisodeRequested.emit();
@@ -127,6 +137,48 @@ describe('PortalInlinePlayerComponent', () => {
expect(events).toEqual(['ended', 'previous', 'next']);
});
it('forwards the exact resolved playback fallback request unchanged', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.test/live.m3u8',
title: 'Header-bearing live stream',
isLive: true,
headers: { Authorization: 'Bearer token' },
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'live',
},
};
const request: PlaybackFallbackRequest = {
player: 'mpv',
playback,
diagnostic: {
code: 'network-error',
player: 'videojs',
source: 'hls',
container: '',
mimeType: '',
videoCodecs: [],
audioCodecs: [],
externalFallbackRecommended: true,
},
};
let forwarded: PlaybackFallbackRequest | undefined;
component.externalFallbackRequested.subscribe(
(value) => (forwarded = value)
);
fixture.componentRef.setInput('playback', playback);
fixture.detectChanges();
const webPlayer = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
webPlayer.externalFallbackRequested.emit(request);
expect(forwarded).toBe(request);
expect(forwarded?.playback).toBe(playback);
});
describe('playerMediaTitle', () => {
it('uses the series title with the episode label for episode playback', () => {
fixture.componentRef.setInput('playback', {
@@ -254,6 +306,10 @@ describe('PortalInlinePlayerComponent', () => {
.compileComponents();
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
fixture.componentRef.setInput(
'playbackSessionKey',
'host-owned-session-key'
);
component = fixture.componentInstance;
});
@@ -308,6 +364,10 @@ describe('PortalInlinePlayerComponent', () => {
.compileComponents();
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
fixture.componentRef.setInput(
'playbackSessionKey',
'host-owned-session-key'
);
component = fixture.componentInstance;
}
@@ -63,6 +63,7 @@ const RAIL_STAGE_GAP = 18;
},
})
export class PortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly episodeMetadata = input<SeriesEpisodeMetadata | null>(null);
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
@@ -77,8 +78,7 @@ export class PortalInlinePlayerComponent {
readonly title = computed(() =>
applyChannelNameStrip(
this.playback()?.title,
this.playback()?.isLive &&
this.settingsStore.stripCountryPrefix?.()
this.playback()?.isLive && this.settingsStore.stripCountryPrefix?.()
)
);
readonly streamUrl = computed(() => this.playback()?.streamUrl ?? '');
@@ -149,18 +149,15 @@ export class PortalInlinePlayerComponent {
return { primary, secondary: metadata?.label ?? null };
});
private readonly stageViewport = viewChild<ElementRef<HTMLElement>>(
'stageViewport'
);
private readonly stageViewport =
viewChild<ElementRef<HTMLElement>>('stageViewport');
/**
* Border-box size of the theater stage, written by a ResizeObserver.
* Measuring the border box (not the content box) keeps the value stable
* when the rail modifier toggles the stage's own padding, so the gate
* below cannot oscillate around its threshold.
*/
readonly stageSize = signal<{ width: number; height: number } | null>(
null
);
readonly stageSize = signal<{ width: number; height: number } | null>(null);
/**
* Width the rail would actually get: the stage minus its docked-mode
* padding, the 16:9 player sized to the remaining height, and the flex
@@ -265,6 +265,7 @@
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playbackSessionKey]="playbackSessionKey()"
[playback]="playback"
(timeUpdate)="onInlineTimeUpdate($event)"
(closed)="closeInlinePlayback()"
@@ -1,5 +1,6 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
@@ -118,6 +119,10 @@ describe('VodDetailsComponent offline playback', () => {
} = {}) => {
fixture = TestBed.createComponent(VodDetailsComponent);
fixture.componentRef.setInput('item', STALKER_VOD);
fixture.componentRef.setInput(
'playbackSessionKey',
'stalker-host-owned-key'
);
fixture.componentRef.setInput('playbackPosition', playbackPosition);
fixture.componentRef.setInput('externalPlayback', externalPlayback);
fixture.componentRef.setInput('providerOnly', providerOnly);
@@ -128,6 +133,24 @@ describe('VodDetailsComponent offline playback', () => {
await fixture.whenStable();
};
it('passes the host-owned session key unchanged to inline playback', async () => {
await render();
fixture.componentRef.setInput('inlinePlayback', {
streamUrl: 'https://portal.example/movie.mp4',
title: 'Catalog Movie',
});
fixture.detectChanges();
const inlinePlayer = fixture.debugElement.query(
By.css('app-portal-inline-player')
).componentInstance as {
playbackSessionKey(): string;
};
expect(inlinePlayer.playbackSessionKey()).toBe(
'stalker-host-owned-key'
);
});
const completeDownload = (
fileAvailability: DownloadItem['fileAvailability'] = 'available'
) => {
@@ -79,6 +79,7 @@ export class VodDetailsComponent {
/** VOD item with discriminated union type */
readonly item = input.required<VodDetailsItem>();
readonly playbackSessionKey = input.required<string>();
/** Whether this item is in favorites (managed by parent) */
readonly isFavorite = input<boolean>(false);
@@ -96,6 +96,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
.compileComponents();
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -124,6 +125,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
fixture.destroy();
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -156,6 +156,7 @@ describe('WebPlayerViewComponent', () => {
storageMap.get.mockReturnValue(of({ player: VideoPlayer.VideoJs }));
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -596,6 +597,7 @@ describe('WebPlayerViewComponent', () => {
const pendingSettings = new Subject<unknown>();
storageMap.get.mockReturnValue(pendingSettings.asObservable());
fixture = TestBed.createComponent(WebPlayerViewComponent);
fixture.componentRef.setInput('playbackSessionKey', 'test-session');
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
@@ -97,6 +97,7 @@ export class WebPlayerViewComponent implements OnDestroy {
private readonly settingsStore = inject(SettingsStore);
streamUrl = input.required<string>();
readonly playbackSessionKey = input.required<string>();
title = input('');
playback = input<ResolvedPortalPlayback | null>(null);
startTime = input<number>(0);
@@ -136,7 +137,10 @@ export class WebPlayerViewComponent implements OnDestroy {
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
);
readonly hiddenAlternativeCount = computed(() =>
Math.max(0, this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES)
Math.max(
0,
this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES
)
);
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
@@ -221,7 +225,9 @@ export class WebPlayerViewComponent implements OnDestroy {
}
return { primary: title, secondary: null };
});
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
readonly recordingFolder = computed(
() => this.settings()?.recordingFolder ?? ''
);
/** Stream URL the currently configured Electron header override belongs to. */
private headerScopeStreamUrl: string | null = null;
@@ -4,13 +4,17 @@ import { TranslateService } from '@ngx-translate/core';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util';
import { GlobalFavoritesService } from './global-favorites.service';
import { of } from 'rxjs';
describe('GlobalFavoritesService', () => {
let service: GlobalFavoritesService;
let electronApi: {
dbGetAppState: jest.Mock;
dbReorderGlobalFavorites: jest.Mock;
dbSetAppState: jest.Mock;
};
const storeSelect = jest.fn();
const getPlaylistById = jest.fn();
const makeChannel = (
overrides: Partial<UnifiedFavoriteChannel> &
@@ -25,7 +29,10 @@ describe('GlobalFavoritesService', () => {
});
beforeEach(() => {
storeSelect.mockReset().mockReturnValue(of([]));
getPlaylistById.mockReset();
electronApi = {
dbGetAppState: jest.fn().mockResolvedValue(null),
dbReorderGlobalFavorites: jest
.fn()
.mockResolvedValue({ success: true }),
@@ -39,9 +46,12 @@ describe('GlobalFavoritesService', () => {
TestBed.configureTestingModule({
providers: [
GlobalFavoritesService,
{ provide: Store, useValue: { select: jest.fn() } },
{ provide: Store, useValue: { select: storeSelect } },
{ provide: DatabaseService, useValue: {} },
{ provide: PlaylistsService, useValue: {} },
{
provide: PlaylistsService,
useValue: { getPlaylistById },
},
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
@@ -58,6 +68,52 @@ describe('GlobalFavoritesService', () => {
});
});
it('keeps legacy URL UIDs aligned with the persisted M3U order', async () => {
const firstUrl = 'https://streams.example/one.ts';
const secondUrl = 'https://streams.example/two.ts';
storeSelect.mockReturnValue(
of([
{
_id: 'playlist-one',
title: 'Playlist',
favorites: [firstUrl, secondUrl],
},
])
);
getPlaylistById.mockReturnValue(
of({
playlist: {
items: [
{
id: 'channel-one',
name: 'Channel One',
url: firstUrl,
},
{
id: 'channel-two',
name: 'Channel Two',
url: secondUrl,
},
],
},
})
);
electronApi.dbGetAppState.mockResolvedValue(
JSON.stringify([
`m3u::playlist-one::${secondUrl}`,
`m3u::playlist-one::${firstUrl}`,
])
);
const channels = await service.getUnifiedLiveFavorites();
expect(channels.map((channel) => channel.uid)).toEqual([
`m3u::playlist-one::${secondUrl}`,
`m3u::playlist-one::${firstUrl}`,
]);
expect(channels.map((channel) => channel.position)).toEqual([0, 1]);
});
describe('reorder', () => {
it('sends playlist-scoped position updates for Xtream favorites', async () => {
// The backend UPDATE filters by (contentId, playlistId); a payload
@@ -109,7 +165,10 @@ describe('GlobalFavoritesService', () => {
expect(electronApi.dbReorderGlobalFavorites).not.toHaveBeenCalled();
expect(electronApi.dbSetAppState).toHaveBeenCalledWith(
'global-favorites-channel-order-v1',
JSON.stringify(['m3u::playlist-m::url', 'stalker::playlist-s::5'])
JSON.stringify([
'm3u::playlist-m::url',
'stalker::playlist-s::5',
])
);
});
});