mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
5e4f2ca3dd
commit
fd96b85c19
176 files changed
+15228
-1908
No files matched your search
+1
@@ -8,6 +8,7 @@
|
||||
<app-stalker-series-view [providerOnly]="providerOnly()" />
|
||||
} @else if (vodDetailsItem()) {
|
||||
<app-vod-details
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[item]="vodDetailsItem()!"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
[playbackPosition]="selectedVodPlaybackPosition()"
|
||||
|
||||
+144
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
+37
-3
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
+1
@@ -15,6 +15,7 @@
|
||||
} @else if (vodDetailsItem(); as item) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[item]="item"
|
||||
[isFavorite]="isFavorite()"
|
||||
[playbackPosition]="playbackPosition()"
|
||||
|
||||
+6
@@ -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([
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+10
-3
@@ -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()"
|
||||
|
||||
+68
-22
@@ -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',
|
||||
|
||||
+81
-25
@@ -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)) {
|
||||
|
||||
+294
@@ -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(),
|
||||
|
||||
+278
@@ -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('');
|
||||
}
|
||||
);
|
||||
});
|
||||
+237
@@ -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;
|
||||
}
|
||||
+1
@@ -167,6 +167,7 @@
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[playback]="playback"
|
||||
[episodeMetadata]="inlineEpisodeMetadata()"
|
||||
[seriesTitle]="serial.info.name"
|
||||
|
||||
+8
-6
@@ -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();
|
||||
|
||||
+185
-236
@@ -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);
|
||||
|
||||
+753
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user