feat(playback): recommend recovery actions (#1374)

* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
This commit is contained in:
4gray authored and GitHub committed 2026-08-08 01:04:39 +02:00
1 parent 5e4f2ca3dd
commit fd96b85c19
176 files changed
+15228 -1908

No files matched your search

@@ -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,43 @@ 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: [],
},
};
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 +1159,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 +1231,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,278 @@
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 {
captureStalkerEpisodePlaybackSessionIdentity,
createStalkerEpisodePlaybackSessionKey,
resolveStalkerEpisodeStateByIdentity,
resolveStalkerEpisodeStateByStructuralIdentity,
toStalkerEpisodePlaybackStructuralIdentity,
} 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,
])(
'keeps %s provider credentials out of the structural key',
(seriesMode) => {
const originalCmdA =
'https://user:password@stream.example/episode.mpg?access_token=secret-a';
const originalCmdB =
'https://user:password@stream.example/episode.mpg?access_token=secret-b';
const stateA = episodeState({ originalCmd: originalCmdA });
const stateB = episodeState({ originalCmd: originalCmdB });
const composite = JSON.stringify([
seriesMode,
'parent|series',
stateA.seasonKey,
stateA.seasonNumber,
stateA.episodeNumber,
]);
const keyA = createStalkerEpisodePlaybackSessionKey({
sourceId: 'playlist|source',
parentSeriesId: 'parent|series',
seriesMode,
episodeState: stateA,
});
const keyB = createStalkerEpisodePlaybackSessionKey({
sourceId: 'playlist|source',
parentSeriesId: 'parent|series',
seriesMode,
episodeState: stateB,
});
expect(keyA).toBe(
createPlaybackSessionKey({
kind: 'episode',
sourceId: 'playlist|source',
contentId: composite,
seriesId: 'parent|series',
seasonNumber: 1,
episodeNumber: 2,
})
);
expect(keyB).toBe(keyA);
expect(keyA).not.toContain(originalCmdA);
expect(keyA).not.toContain('password');
expect(keyA).not.toContain('secret-a');
}
);
it('retains the exact pending guard without a synthesized tracking hash', () => {
const originalCmd =
'https://stream.example.invalid/episode.mpg?access_token=example-token';
const state = episodeState({
id: 'command-derived-tracking-hash',
originalCmd,
});
const identity = captureStalkerEpisodePlaybackSessionIdentity({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: state,
});
if (!identity) throw new Error('Expected a captured request identity');
expect(identity).toEqual({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
originalEpisodeIdentity: originalCmd,
seasonKey: state.seasonKey,
seasonNumber: state.seasonNumber,
episodeNumber: state.episodeNumber,
sessionKey: identity.sessionKey,
});
});
it('freezes only credential-free structural fields for a mounted session', () => {
const originalCmd =
'https://user:password@stream.example/episode.mpg?access_token=secret';
const identity = captureStalkerEpisodePlaybackSessionIdentity({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: episodeState({ originalCmd }),
});
if (!identity) throw new Error('Expected a captured request identity');
const retained = toStalkerEpisodePlaybackStructuralIdentity(identity);
expect(Object.isFrozen(retained)).toBe(true);
expect(retained).toEqual({
sourceId: identity.sourceId,
parentSeriesId: identity.parentSeriesId,
seriesMode: identity.seriesMode,
seasonKey: identity.seasonKey,
seasonNumber: identity.seasonNumber,
episodeNumber: identity.episodeNumber,
sessionKey: identity.sessionKey,
});
expect(JSON.stringify(retained)).not.toContain(originalCmd);
expect(retained).not.toHaveProperty('originalEpisodeIdentity');
});
it('fails closed when structural coordinates match multiple episodes', () => {
const state = episodeState({ originalCmd: 'command-a' });
const requestIdentity = captureStalkerEpisodePlaybackSessionIdentity({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: state,
});
if (!requestIdentity) {
throw new Error('Expected a captured request identity');
}
const identity =
toStalkerEpisodePlaybackStructuralIdentity(requestIdentity);
expect(
resolveStalkerEpisodeStateByStructuralIdentity({
episodesBySeason: {
[state.seasonKey]: [
state.episode,
{
...state.episode,
id: 'another-tracking-id',
originalCmd: 'command-b',
},
],
},
identity,
})
).toBeNull();
});
it('keeps lazy provider ids transient while rejecting an id refresh for a pending request', () => {
const stateA = episodeState({ originalId: 'provider|episode:token-a' });
const stateB = episodeState({ originalId: 'provider|episode:token-b' });
const options = {
sourceId: 'playlist',
parentSeriesId: '50001',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
} as const;
const identityA = captureStalkerEpisodePlaybackSessionIdentity({
...options,
episodeState: stateA,
});
const keyB = createStalkerEpisodePlaybackSessionKey({
...options,
episodeState: stateB,
});
if (!identityA) throw new Error('Expected a captured request identity');
expect(identityA.originalEpisodeIdentity).toBe(
'provider|episode:token-a'
);
expect(keyB).toBe(identityA.sessionKey);
expect(keyB).not.toContain('provider|episode:token-a');
expect(keyB).not.toContain('provider|episode:token-b');
expect(keyB).not.toContain('synthesized-32-bit-id');
expect(
resolveStalkerEpisodeStateByIdentity({
episodesBySeason: { [stateB.seasonKey]: [stateB.episode] },
identity: identityA,
})
).toBeNull();
});
it('separates every structural coordinate without delimiter collisions', () => {
const base = {
sourceId: 'playlist|a',
parentSeriesId: 'parent|a',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: episodeState({ originalCmd: 'cmd|a' }),
} as const;
const variants = [
{ ...base, sourceId: 'playlist|b' },
{ ...base, parentSeriesId: 'parent|b' },
{
...base,
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
},
{
...base,
episodeState: episodeState(
{ originalCmd: 'cmd|a' },
'season|2'
),
},
{
...base,
episodeState: {
...base.episodeState,
seasonNumber: 2,
},
},
{
...base,
episodeState: {
...base.episodeState,
episodeNumber: 3,
},
},
];
const baseKey = createStalkerEpisodePlaybackSessionKey(base);
const delimiterVariantKey = createStalkerEpisodePlaybackSessionKey({
...base,
sourceId: 'playlist',
parentSeriesId: 'a|parent|a',
});
expect(
variants.map(createStalkerEpisodePlaybackSessionKey)
).not.toContain(baseKey);
expect(delimiterVariantKey).not.toBe(baseKey);
});
it.each([
{
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
state: episodeState({}),
},
{
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
state: episodeState({ originalCmd: '' }),
},
{
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
state: episodeState({}),
},
{
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
state: episodeState({ originalId: '' }),
},
])(
'fails closed when original episode identity is absent',
({ seriesMode, state }) => {
expect(
createStalkerEpisodePlaybackSessionKey({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode,
episodeState: state,
})
).toBe('');
}
);
});
@@ -0,0 +1,237 @@
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 sessionKey: string;
}
export interface StalkerEpisodePlaybackStructuralIdentity {
readonly sourceId: string;
readonly parentSeriesId: string;
readonly seriesMode: StalkerSeriesDownloadMode;
readonly seasonKey: string;
readonly seasonNumber: number;
readonly episodeNumber: 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;
}
// Recovery ownership uses provider commands and IDs only for the transient
// request guard below. They may contain credentials and must never enter
// the session key or retained recovery state.
const structuralContentId = JSON.stringify([
seriesMode,
seriesId,
seasonKey,
seasonNumber,
episodeNumber,
]);
const sessionKey = createPlaybackSessionKey({
kind: 'episode',
sourceId: normalizedSourceId,
contentId: structuralContentId,
seriesId,
seasonNumber,
episodeNumber,
});
return Object.freeze({
sourceId: normalizedSourceId,
parentSeriesId: seriesId,
seriesMode,
originalEpisodeIdentity: originalIdentity,
seasonKey,
seasonNumber,
episodeNumber,
sessionKey,
});
}
export function createStalkerEpisodePlaybackSessionKey(
options: StalkerEpisodePlaybackSessionKeyOptions
): string {
return (
captureStalkerEpisodePlaybackSessionIdentity(options)?.sessionKey ?? ''
);
}
export function toStalkerEpisodePlaybackStructuralIdentity(
identity: StalkerEpisodePlaybackSessionIdentity
): StalkerEpisodePlaybackStructuralIdentity {
const {
sourceId,
parentSeriesId,
seriesMode,
seasonKey,
seasonNumber,
episodeNumber,
sessionKey,
} = identity;
return Object.freeze({
sourceId,
parentSeriesId,
seriesMode,
seasonKey,
seasonNumber,
episodeNumber,
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
);
}
);
}
export function resolveStalkerEpisodeStateByStructuralIdentity({
episodesBySeason,
identity,
}: StalkerEpisodeStateLookupOptions & {
readonly identity: StalkerEpisodePlaybackStructuralIdentity;
}): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
return findStalkerEpisodeState(
{ [identity.seasonKey]: episodesBySeason[identity.seasonKey] ?? [] },
(_candidate, state) =>
state.seasonNumber === identity.seasonNumber &&
state.episodeNumber === identity.episodeNumber,
true
);
}
function findStalkerEpisodeState(
episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>,
matches: (
episode: XtreamSerieEpisode,
state: SeriesPlaybackEpisodeState<XtreamSerieEpisode>
) => boolean,
requireUnique = false
): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
let match: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null = 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)) continue;
if (!requireUnique) return state;
if (match) return null;
match = state;
}
}
return match;
}
@@ -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,15 @@ import {
createStalkerSeriesDownloadAdapter,
STALKER_SERIES_DOWNLOAD_MODES,
} from './stalker-series-download.adapter';
import {
captureStalkerEpisodePlaybackSessionIdentity,
resolveSelectedStalkerEpisodeState,
resolveStalkerEpisodeStateByIdentity,
resolveStalkerEpisodeStateByStructuralIdentity,
toStalkerEpisodePlaybackStructuralIdentity,
type StalkerEpisodePlaybackSessionIdentity,
type StalkerEpisodePlaybackStructuralIdentity,
} from './stalker-episode-playback-session-key';
interface SeriesPositionContext {
readonly generation: number;
@@ -93,46 +101,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 +187,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
>();
private readonly seriesPositionReloadKeys = new Set<string>();
private seriesPositionsLoadGeneration = 0;
private seriesPlaybackRequestGeneration = 0;
private currentSeriesPlaybackOwnerKey = '';
private readonly inlinePlaybackEpisodeIdentity =
signal<StalkerEpisodePlaybackStructuralIdentity | null>(null);
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
@@ -308,6 +284,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 +407,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
) {
return;
}
this.episodePlaybackPositions.set(
reconciled.positionsByTrackingId
);
this.episodePlaybackPositions.set(reconciled.positionsByTrackingId);
this.legacyPositionByTrackingId.set(
reconciled.legacyPositionByTrackingId
);
@@ -488,15 +467,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 +512,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 +539,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 +581,25 @@ export class StalkerSeriesViewComponent implements OnDestroy {
vodSeriesSeasons: this.vodSeriesSeasons(),
});
});
readonly inlineEpisodeState =
computed<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(() =>
this.getInlineEpisodeState()
);
readonly inlineEpisodeState = computed(() => {
const identity = this.inlinePlaybackEpisodeIdentity();
const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? '';
const parentSeriesId = normalizeStalkerEntityId(this.displayItem()?.id);
if (
!identity ||
this.playbackSessionKey() !== identity.sessionKey ||
sourceId !== identity.sourceId ||
parentSeriesId !== identity.parentSeriesId ||
this.seriesMode() !== identity.seriesMode
) {
return null;
}
return resolveStalkerEpisodeStateByStructuralIdentity({
episodesBySeason: this.mappedSeasons(),
identity,
});
});
readonly playbackSessionKey = signal('');
readonly inlineEpisodeMetadata = computed(() =>
getSeriesEpisodeMetadata(this.inlineEpisodeState())
);
@@ -768,27 +773,33 @@ export class StalkerSeriesViewComponent implements OnDestroy {
* Handles episode click from the container
*/
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 mappedEpisode = episodeState.episode as StalkerMappedEpisode;
const isLazyVod = mappedEpisode.custom_sid === 'vod-series';
const command = isLazyVod
? `/media/file_${mappedEpisode.originalId ?? ''}.mpg`
: mappedEpisode.originalCmd;
const title = isLazyVod
? `${item.info.name} - ${mappedEpisode.title || `Episode ${episodeState.episodeNumber}`}`
: item.info.name;
const trackingId = Number(mappedEpisode.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 +818,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 +855,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
closeInlinePlayer(): void {
this.seriesPlaybackRequestGeneration += 1;
this.inlinePlayback.set(null);
this.inlinePlaybackEpisodeIdentity.set(null);
this.playbackSessionKey.set('');
this.lastSaveTime = 0;
}
@@ -984,13 +935,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 +965,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 +980,15 @@ 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);
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 +1002,48 @@ 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,
identity: StalkerEpisodePlaybackSessionIdentity
): void {
this.playbackSessionKey.set(identity.sessionKey);
this.inlinePlaybackEpisodeIdentity.set(
toStalkerEpisodePlaybackStructuralIdentity(identity)
);
this.inlinePlayback.set(playback);
}
private syncSeriesPlaybackOwner(ownerKey: string): void {
if (ownerKey === this.currentSeriesPlaybackOwnerKey) return;
this.currentSeriesPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
}
async handlePlaybackToggleRequested(
@@ -1072,17 +1055,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 +1081,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 +1130,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 +1148,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 +1157,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 +1190,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 +1246,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 +1260,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
});
} catch (error) {
if (
error instanceof
StalkerSeriesPositionPartialSaveError &&
error instanceof StalkerSeriesPositionPartialSaveError &&
this.isSeriesPositionContextActive(context)
) {
this.publishSavedSeriesPosition(
@@ -1329,14 +1287,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 +1298,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.rawSeriesPositions.set([
...this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(
candidate.contentXtreamId
)
!removedTrackingIds.has(candidate.contentXtreamId)
),
position,
]);
@@ -1361,14 +1313,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 +1350,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 +1358,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,753 @@
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 tmdbGetSeason = 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,
},
})
);
tmdbGetSeason.mockReset().mockResolvedValue(null);
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: tmdbGetSeason,
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('preserves the logical key when the provider command rotates', 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()).toBe(firstKey);
expect(firstKey).not.toContain('ffrt4://regular|full-command');
expect(firstKey).not.toContain('ffrt4://different|full-command');
});
it('rejects a pending command refresh but lets the refreshed episode reuse its logical key', async () => {
fixture.detectChanges();
await fixture.whenStable();
const pending = deferred<ResolvedPortalPlayback>();
resolveVodPlayback.mockReturnValueOnce(pending.promise);
const [seasonKeyA, episodesA] = Object.entries(
fixture.componentInstance.mappedSeasons()
)[0];
const episodeA = episodesA[0] as StalkerMappedEpisode;
const expectedKey = createStalkerEpisodePlaybackSessionKey({
sourceId: 'stalker|playlist',
parentSeriesId: 'series|parent',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: {
seasonKey: seasonKeyA,
seasonNumber: Number(episodeA.season) || Number(seasonKeyA),
episodeNumber: Number(episodeA.episode_num),
episode: episodeA,
previous: null,
next: null,
},
});
fixture.componentInstance.onEpisodeClicked(episodeA);
serialSeasons.set([
{
id: 'replacement-season',
name: 'Season 1',
cmd: 'ffrt4://refreshed|full-command',
series: [1, 2],
},
]);
fixture.detectChanges();
const [seasonKeyB, episodesB] = Object.entries(
fixture.componentInstance.mappedSeasons()
)[0];
const episodeB = episodesB[0] as StalkerMappedEpisode;
const refreshedKey = createStalkerEpisodePlaybackSessionKey({
sourceId: 'stalker|playlist',
parentSeriesId: 'series|parent',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: {
seasonKey: seasonKeyB,
seasonNumber: Number(episodeB.season) || Number(seasonKeyB),
episodeNumber: Number(episodeB.episode_num),
episode: episodeB,
previous: null,
next: episodesB[1] ?? null,
},
});
expect(episodeB.originalCmd).not.toBe(episodeA.originalCmd);
expect(episodeB.id).not.toBe(episodeA.id);
expect(refreshedKey).toBe(expectedKey);
pending.resolve({
streamUrl: 'https://stale.example/old-command.mpg',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: episodeA.id,
contentType: 'episode',
},
});
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe('');
expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled();
fixture.componentInstance.onEpisodeClicked(episodeB);
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'ffrt4://refreshed|full-command',
'Series',
'poster.jpg',
1,
Number(episodeB.id),
undefined
);
expect(fixture.componentInstance.inlinePlayback()).not.toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
expectedKey
);
});
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('re-resolves mounted episode metadata and neighbors after a same-owner season refresh', async () => {
serialSeasons.set([
{
id: 'regular-season',
name: 'Season 1',
cmd: 'ffrt4://regular|full-command',
series: [1, 2, 3],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const initialEpisodes = Object.values(
fixture.componentInstance.mappedSeasons()
)[0] as StalkerMappedEpisode[];
fixture.componentInstance.onEpisodeClicked(initialEpisodes[1]);
await fixture.whenStable();
const key = fixture.componentInstance.playbackSessionKey();
serialSeasons.set([
{
id: 'refreshed-season',
name: 'Season 1',
cmd: 'ffrt4://refreshed|full-command',
series: [1, 2, 3],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const refreshedEpisodes = Object.values(
fixture.componentInstance.mappedSeasons()
)[0] as StalkerMappedEpisode[];
const state = fixture.componentInstance.inlineEpisodeState();
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
expect(state?.episode).toBe(refreshedEpisodes[1]);
expect(state?.previous).toBe(refreshedEpisodes[0]);
expect(state?.next).toBe(refreshedEpisodes[2]);
fixture.componentInstance.handleInlinePlaybackEnded();
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenCalledTimes(2);
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'ffrt4://refreshed|full-command',
'Series',
'poster.jpg',
3,
Number(refreshedEpisodes[2].id),
undefined
);
});
it('re-resolves mounted episode metadata after same-owner TMDB enrichment', async () => {
await playFirstEpisode();
const key = fixture.componentInstance.playbackSessionKey();
tmdbGetSeason.mockResolvedValue({
overview: 'Season overview',
episodes: [{ episode_number: 1, name: 'TMDB Pilot' }],
});
const item = selectedItem();
if (!item) throw new Error('Expected the selected series');
selectedItem.set({
...item,
info: { ...item.info, tmdb_id: 314 },
});
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const currentEpisode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0];
expect(tmdbGetSeason).toHaveBeenCalledWith(314, 1);
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
expect(fixture.componentInstance.inlineEpisodeState()?.episode).toBe(
currentEpisode
);
expect(fixture.componentInstance.inlineEpisodeMetadata()?.title).toBe(
'TMDB Pilot'
);
});
it('uses the current mapped episode when a stale lazy-series event resolves', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: 'series|parent',
is_series: true,
info: {
name: 'Lazy series',
movie_image: 'poster.jpg',
tmdb_id: 2718,
},
});
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'lazy-season',
video_id: 'series|parent',
season_number: '2',
name: 'Season 2',
episodes: [
{
id: 'provider-episode',
series_number: 3,
name: 'Episode 3',
},
],
isLoading: false,
isExpanded: false,
},
]);
fixture.detectChanges();
await fixture.whenStable();
const staleEpisode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
tmdbGetSeason.mockResolvedValue({
episodes: [{ episode_number: 3, name: 'Current TMDB title' }],
});
fixture.componentInstance.onSeasonSelected('2');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(
Object.values(fixture.componentInstance.mappedSeasons())[0][0].title
).toBe('Current TMDB title');
fixture.componentInstance.onEpisodeClicked(staleEpisode);
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'/media/file_provider-episode.mpg',
'Lazy series - Current TMDB title',
'poster.jpg',
3,
expect.any(Number),
undefined
);
});
it('fails mounted episode commands closed when a same-owner refresh removes the episode', async () => {
serialSeasons.set([
{
id: 'regular-season',
name: 'Season 1',
cmd: 'ffrt4://regular|full-command',
series: [1, 2, 3],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const episodes = Object.values(
fixture.componentInstance.mappedSeasons()
)[0] as StalkerMappedEpisode[];
fixture.componentInstance.onEpisodeClicked(episodes[1]);
await fixture.whenStable();
const key = fixture.componentInstance.playbackSessionKey();
serialSeasons.set([
{
id: 'refreshed-season',
name: 'Season 1',
cmd: 'ffrt4://refreshed|full-command',
series: [1],
},
]);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
expect(fixture.componentInstance.inlineEpisodeState()).toBeNull();
expect(fixture.componentInstance.inlineEpisodeMetadata()).toBeNull();
expect(fixture.componentInstance.inlineSeriesNavigation()).toBeNull();
fixture.componentInstance.playPreviousEpisode();
fixture.componentInstance.playNextEpisode();
fixture.componentInstance.handleInlinePlaybackEnded();
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenCalledTimes(1);
});
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'),