mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(portals): preserve playlist ownership during detail handoffs
This commit is contained in:
1 parent
bf3cf87b65
commit
2283d368d3
21 files changed
+920
-52
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: portals
|
||||
---
|
||||
|
||||
Opening a title in its portal from favorites, history or dashboard keeps the correct playlist and categories. Switching portals no longer lets an older loading request replace the current selection, and Stalker details wait for their destination portal before opening.
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
goToDashboard,
|
||||
openGlobalRecent,
|
||||
openWorkspaceSection,
|
||||
openPlaylistFavorites,
|
||||
openSources,
|
||||
@@ -41,8 +43,158 @@ import {
|
||||
getXtreamTitle,
|
||||
pickDistinctTitles,
|
||||
} from './portal-mock-fixtures';
|
||||
import { readVisibleSidebarCategoryNames } from './sidebar-categories.e2e-support';
|
||||
import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
|
||||
test.describe('Electron Favorites', () => {
|
||||
for (const origin of ['favorites', 'recent', 'dashboard'] as const) {
|
||||
test(`@stalker @electron portal handoff from ${origin} restores the owning categories after opening a different source`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const vod = await fetchStalkerCategoryFixture(request, 'vod');
|
||||
const [title] = pickDistinctTitles(vod.items, getStalkerTitle);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
await routePlayableStreams(page);
|
||||
try {
|
||||
await addStalkerPortal(page, { name: 'Portal A' });
|
||||
await waitForStalkerCatalog(page);
|
||||
await clickCategoryById(page, vod.categoryId);
|
||||
const portalPath = new URL(page.url()).pathname.replace(
|
||||
/\/vod(?:\/[^/]+)?$/,
|
||||
`/vod/${vod.categoryId}`
|
||||
);
|
||||
const categoriesA = await readVisibleSidebarCategoryNames(page);
|
||||
await clickGridListCardByTitle(page, title);
|
||||
await addCurrentDetailToFavorites(page);
|
||||
if (origin !== 'favorites') {
|
||||
await startAndConfirmPlayback(page, () =>
|
||||
page.locator('button.play-btn').first().click()
|
||||
);
|
||||
}
|
||||
await goBackFromDetail(page);
|
||||
if (origin !== 'favorites') {
|
||||
// Represent an already saved viewing position. The short
|
||||
// media fixture ends before the player's 15 s save interval.
|
||||
const playlistId = /\/stalker\/([^/]+)/.exec(
|
||||
portalPath
|
||||
)?.[1];
|
||||
const contentId = Number(
|
||||
vod.items.find(
|
||||
(item) => getStalkerTitle(item) === title
|
||||
)?.id
|
||||
);
|
||||
expect(playlistId).toBeTruthy();
|
||||
await page.evaluate(
|
||||
async ({ playlistId, contentId }) => {
|
||||
await window.electron.dbSavePlaybackPosition(
|
||||
playlistId!,
|
||||
{
|
||||
contentXtreamId: contentId,
|
||||
contentType: 'vod',
|
||||
playlistType: 'stalker',
|
||||
positionSeconds: 2,
|
||||
durationSeconds: 6,
|
||||
}
|
||||
);
|
||||
},
|
||||
{ playlistId, contentId }
|
||||
);
|
||||
}
|
||||
|
||||
await openSources(page);
|
||||
await addStalkerPortal(page, {
|
||||
name: 'Portal B',
|
||||
macAddress: '00:1A:79:00:00:03',
|
||||
});
|
||||
await waitForStalkerCatalog(page);
|
||||
const categoriesB = await readVisibleSidebarCategoryNames(page);
|
||||
expect(categoriesB).not.toEqual(categoriesA);
|
||||
|
||||
if (origin === 'dashboard') {
|
||||
await goToDashboard(page);
|
||||
await page
|
||||
.locator(
|
||||
'[data-test-id="dashboard-continue-watching-rail-card"]'
|
||||
)
|
||||
.filter({ hasText: title })
|
||||
.first()
|
||||
.click();
|
||||
} else {
|
||||
if (origin === 'favorites')
|
||||
await openWorkspaceSection(page, 'Global favorites');
|
||||
else await openGlobalRecent(page);
|
||||
await switchUnifiedCollectionScope(page, 'All playlists');
|
||||
await contentCardByTitle(page, title).first().click();
|
||||
}
|
||||
await expect(page.locator('app-content-hero')).toContainText(
|
||||
title
|
||||
);
|
||||
if (origin !== 'favorites') {
|
||||
await startAndConfirmPlayback(
|
||||
page,
|
||||
() =>
|
||||
page
|
||||
.getByRole('button', { name: /^Continue/ })
|
||||
.first()
|
||||
.click(),
|
||||
0.5
|
||||
);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Close player',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
}
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'View in portal',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname)
|
||||
.toBe(portalPath);
|
||||
await expect(page.locator('app-content-hero')).toContainText(
|
||||
title
|
||||
);
|
||||
await expect
|
||||
.poll(() => readVisibleSidebarCategoryNames(page))
|
||||
.toEqual(categoriesA);
|
||||
|
||||
await goBackFromDetail(page);
|
||||
await expectPathname(
|
||||
page,
|
||||
origin === 'favorites'
|
||||
? /\/workspace\/global-favorites$/
|
||||
: /\/workspace\/global-recent$/
|
||||
);
|
||||
await expect(page.locator('app-content-hero')).toContainText(
|
||||
title
|
||||
);
|
||||
for (const [name, expected] of [
|
||||
['Portal B', categoriesB],
|
||||
['Portal A', categoriesA],
|
||||
] as const) {
|
||||
await openSources(page);
|
||||
await sourceRowByTitle(page, name).first().click();
|
||||
await waitForStalkerCatalog(page);
|
||||
await expect
|
||||
.poll(() => readVisibleSidebarCategoryNames(page))
|
||||
.toEqual(expected);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('@persistence @m3u @electron shows M3U favorites in playlist and all-playlists scope, and preserves them after restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
|
||||
@@ -371,7 +371,7 @@ test.describe('Electron Recently Viewed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('opens Xtream recent movies and series inline from recent without switching the playlist or showing the sidebar', async ({
|
||||
test('opens Xtream recent movies and series inline and preserves history through the movie portal handoff', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
@@ -405,6 +405,7 @@ test.describe('Electron Recently Viewed', () => {
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
playCurrentDetail(app.mainWindow)
|
||||
);
|
||||
const moviePath = new URL(app.mainWindow.url()).pathname;
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
@@ -435,6 +436,42 @@ test.describe('Electron Recently Viewed', () => {
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
|
||||
const viewInPortal = app.mainWindow.getByRole('button', {
|
||||
name: 'View in portal',
|
||||
exact: true,
|
||||
});
|
||||
await viewInPortal.click();
|
||||
await expect
|
||||
.poll(() => new URL(app.mainWindow.url()).pathname)
|
||||
.toBe(moviePath);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-content-hero')
|
||||
).toContainText(movieTitle);
|
||||
await expect(viewInPortal).toHaveCount(0);
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
playCurrentDetail(app.mainWindow)
|
||||
);
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-recent$/,
|
||||
title: movieTitle,
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
await app.mainWindow.goForward();
|
||||
await expect
|
||||
.poll(() => new URL(app.mainWindow.url()).pathname)
|
||||
.toBe(moviePath);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-content-hero')
|
||||
).toContainText(movieTitle);
|
||||
await app.mainWindow.goBack();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-recent$/,
|
||||
title: movieTitle,
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
@@ -270,6 +270,22 @@ handler as the header Back.
|
||||
- Do not force both portals into the same browse/detail behavior unless the full
|
||||
portal detail architecture is being changed.
|
||||
|
||||
### Arrival and asynchronous ownership
|
||||
|
||||
The shared catalog view initializes the category and consumes its Stalker
|
||||
handoff together, after the facade's optional `routeReady` signal allows it.
|
||||
Stalker exposes the route session's readiness, withheld from NavigationStart
|
||||
until the destination playlist and section are installed. Cancelled navigation
|
||||
reconciles the current route. Reused category routes also reinitialize when
|
||||
their playlist changes; readiness changes alone must not close an open detail
|
||||
on query-only navigation.
|
||||
|
||||
Collection detail wrappers invalidate pending playlist loads before restoring
|
||||
their store snapshot on destruction. Route sessions must compare the actual
|
||||
store owner with the route, rather than trusting only their last initialized
|
||||
playlist id. Xtream playlist reads likewise discard completions superseded by
|
||||
a selection, metadata update, reset or newer read.
|
||||
|
||||
## Xtream
|
||||
|
||||
Xtream category and search details are represented by canonical routes.
|
||||
|
||||
@@ -393,6 +393,14 @@ Internal structure to preserve:
|
||||
`isCategoryResourceFailed()` / `isPaginatedContentFailed()` for explicit
|
||||
error handling.
|
||||
|
||||
Category arrays belong to `categoryPlaylistKey`, not just the content type.
|
||||
Every category reader checks that owner; a portal switch clears all four
|
||||
section caches before loading the destination. Aborted or foreign-portal
|
||||
responses (including errors and radio fallbacks) cannot write into the active
|
||||
cache. Explicit category resets reload even when the playlist object remains
|
||||
unchanged. This contract applies to collection details as well as routed
|
||||
catalogs, because both use the root Stalker store.
|
||||
|
||||
Failure-handling rule:
|
||||
|
||||
- Failed category or content requests must degrade into empty/error UI state,
|
||||
|
||||
+70
-1
@@ -76,7 +76,10 @@ describe('CategoryContentViewComponent', () => {
|
||||
const hasMore = signal(false);
|
||||
const isAppending = signal(false);
|
||||
const appendError = signal(false);
|
||||
const routeReady = signal(true);
|
||||
const playlist = signal<{ id: string } | null>(null);
|
||||
const catalog = {
|
||||
routeReady,
|
||||
provider: 'xtream' as 'xtream' | 'stalker',
|
||||
contentType: signal('vod'),
|
||||
selectedCategory: signal({ id: 1 }),
|
||||
@@ -90,7 +93,7 @@ describe('CategoryContentViewComponent', () => {
|
||||
contentSortMode,
|
||||
supportsRatingSort: true,
|
||||
minRating,
|
||||
playlist: signal(null),
|
||||
playlist,
|
||||
isPaginatedContentLoading,
|
||||
initialize: jest.fn(),
|
||||
setSearchQuery: jest.fn(),
|
||||
@@ -107,6 +110,8 @@ describe('CategoryContentViewComponent', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
routeReady.set(true);
|
||||
playlist.set(null);
|
||||
window.history.replaceState({}, '', window.location.href);
|
||||
catalog.provider = 'xtream';
|
||||
selectedItem.set(null);
|
||||
@@ -239,6 +244,20 @@ describe('CategoryContentViewComponent', () => {
|
||||
expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix');
|
||||
});
|
||||
|
||||
it('preserves route search after category initialization resets the store filter', () => {
|
||||
let search = '';
|
||||
catalog.initialize.mockImplementation(() => {
|
||||
search = '';
|
||||
});
|
||||
catalog.setSearchQuery.mockImplementation((query: string) => {
|
||||
search = query;
|
||||
});
|
||||
queryParamMap$.next(convertToParamMap({ q: 'matrix' }));
|
||||
paramMap$.next(convertToParamMap({ categoryId: '5' }));
|
||||
fixture.detectChanges();
|
||||
expect(search).toBe('matrix');
|
||||
});
|
||||
|
||||
it('groups catalog sort and rating filters behind one refine menu trigger', () => {
|
||||
contentSortMode.set('date-desc');
|
||||
categoryItemCount.set(12);
|
||||
@@ -353,6 +372,56 @@ describe('CategoryContentViewComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for the destination portal before initializing and consuming a Stalker handoff', () => {
|
||||
catalog.provider = 'stalker';
|
||||
routeReady.set(false);
|
||||
const item = { id: '42', name: 'Destination movie' };
|
||||
window.history.replaceState(
|
||||
{ openStalkerItem: item },
|
||||
'',
|
||||
window.location.href
|
||||
);
|
||||
paramMap$.next(convertToParamMap({ categoryId: '5' }));
|
||||
fixture.detectChanges();
|
||||
expect(catalog.initialize).not.toHaveBeenCalled();
|
||||
expect(catalog.selectItem).not.toHaveBeenCalled();
|
||||
expect(window.history.state.openStalkerItem).toEqual(item);
|
||||
|
||||
routeReady.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(catalog.initialize).toHaveBeenCalledWith('5');
|
||||
expect(catalog.selectItem).toHaveBeenCalledWith(item);
|
||||
expect(window.history.state.openStalkerItem).toBeUndefined();
|
||||
|
||||
routeReady.set(false);
|
||||
fixture.detectChanges();
|
||||
routeReady.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(catalog.initialize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reinitializes a reused category route when the ready playlist changes', () => {
|
||||
catalog.provider = 'stalker';
|
||||
playlist.set({ id: 'a' });
|
||||
paramMap$.next(convertToParamMap({ categoryId: '5' }));
|
||||
fixture.detectChanges();
|
||||
routeReady.set(false);
|
||||
fixture.detectChanges();
|
||||
const item = { id: '42', name: 'Movie from B' };
|
||||
window.history.replaceState(
|
||||
{ openStalkerItem: item },
|
||||
'',
|
||||
window.location.href
|
||||
);
|
||||
playlist.set({ id: 'b' });
|
||||
fixture.detectChanges();
|
||||
expect(catalog.initialize).toHaveBeenCalledTimes(1);
|
||||
routeReady.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(catalog.initialize).toHaveBeenCalledTimes(2);
|
||||
expect(catalog.selectItem).toHaveBeenCalledWith(item);
|
||||
});
|
||||
|
||||
it('hands provider-only presentation to the exact Stalker item after consuming navigation state', async () => {
|
||||
const item = { id: '42', category_id: 'vod' };
|
||||
catalog.provider = 'stalker';
|
||||
|
||||
+30
-3
@@ -10,6 +10,7 @@ import {
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { map } from 'rxjs/operators';
|
||||
@@ -193,8 +194,36 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
private previousGridResetKey: string | null = null;
|
||||
private hasAttemptedScrollRestore = false;
|
||||
private readonly routeCategory = signal<{ id: string | null } | null>(null);
|
||||
private initializedRoute: {
|
||||
category: { id: string | null };
|
||||
playlistId: string | null;
|
||||
} | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const category = this.routeCategory();
|
||||
if (!category || this.catalog.routeReady?.() === false) {
|
||||
return;
|
||||
}
|
||||
const playlistId = this.catalog.playlist()?.id ?? null;
|
||||
// Query-only navigation toggles readiness too; only a new
|
||||
// category arrival or playlist should reset the open detail.
|
||||
if (
|
||||
this.initializedRoute?.category === category &&
|
||||
this.initializedRoute.playlistId === playlistId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.initializedRoute = { category, playlistId };
|
||||
untracked(() => {
|
||||
this.providerOnlyStalkerItemId.set(null);
|
||||
this.catalog.initialize(category.id);
|
||||
this.catalog.setSearchQuery?.(this.searchTerm());
|
||||
this.openStalkerItemFromNavigationState();
|
||||
});
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const resetKey = this.gridResetKey();
|
||||
if (
|
||||
@@ -253,9 +282,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
this.activatedRoute.paramMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((params) => {
|
||||
this.providerOnlyStalkerItemId.set(null);
|
||||
this.catalog.initialize(params.get('categoryId'));
|
||||
this.openStalkerItemFromNavigationState();
|
||||
this.routeCategory.set({ id: params.get('categoryId') });
|
||||
});
|
||||
|
||||
this.activatedRoute.queryParamMap
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface PortalCatalogFacade<
|
||||
TSelectedItem = unknown,
|
||||
> {
|
||||
readonly provider: PortalCatalogProvider;
|
||||
/** Arrival state may be consumed only after the destination portal is installed. */
|
||||
readonly routeReady?: Signal<boolean>;
|
||||
readonly contentType: Signal<string | null | undefined>;
|
||||
readonly selectedCategory: Signal<TCategory | null | undefined>;
|
||||
readonly paginatedContent: Signal<readonly TItem[] | undefined>;
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DataService, ParentalLockService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerItvCacheService } from '../../stalker-itv-cache.service';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { StalkerContentType } from '../stalker-store.contracts';
|
||||
import { withStalkerContent } from './with-stalker-content.feature';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({ warn: jest.fn(), error: jest.fn() }),
|
||||
}));
|
||||
|
||||
const Store = signalStore(
|
||||
withState({
|
||||
currentPlaylist: undefined as PlaylistMeta | undefined,
|
||||
selectedContentType: 'vod' as StalkerContentType,
|
||||
selectedCategoryId: null as string | null,
|
||||
searchPhrase: '',
|
||||
page: 0,
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
selectPlaylist(id: string) {
|
||||
patchState(store, {
|
||||
currentPlaylist: {
|
||||
_id: id,
|
||||
portalUrl: `http://${id}.example/portal.php`,
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
isFullStalkerPortal: false,
|
||||
} as PlaylistMeta,
|
||||
});
|
||||
},
|
||||
selectType(selectedContentType: StalkerContentType) {
|
||||
patchState(store, { selectedContentType });
|
||||
},
|
||||
})),
|
||||
withStalkerContent()
|
||||
);
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (value: unknown) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('Stalker category ownership', () => {
|
||||
let store: InstanceType<typeof Store>;
|
||||
let request: jest.Mock;
|
||||
beforeEach(() => {
|
||||
request = jest.fn().mockResolvedValue({ js: [] });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
Store,
|
||||
{ provide: DataService, useValue: { sendIpcEvent: request } },
|
||||
{
|
||||
provide: ParentalLockService,
|
||||
useValue: { active: () => false, version: () => 0 },
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
},
|
||||
{
|
||||
provide: StalkerSessionService,
|
||||
useValue: {
|
||||
ensureToken: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ token: null }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StalkerItvCacheService,
|
||||
useValue: {
|
||||
versionFor: () => 0,
|
||||
getChannels: () => null,
|
||||
isReady: () => false,
|
||||
isLoading: () => false,
|
||||
progressOf: () => null,
|
||||
isUnsupported: () => false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
store = TestBed.inject(Store);
|
||||
void store.isCategoryResourceLoading();
|
||||
});
|
||||
|
||||
it('reloads categories when the active portal cache is explicitly reset', async () => {
|
||||
request.mockResolvedValueOnce({
|
||||
js: [{ id: '1', title: 'Before reset' }],
|
||||
});
|
||||
store.selectPlaylist('a');
|
||||
await settle();
|
||||
request.mockResolvedValueOnce({
|
||||
js: [{ id: '1', title: 'After reset' }],
|
||||
});
|
||||
store.resetCategories();
|
||||
await settle();
|
||||
expect(
|
||||
store.getCategoryResource().map((c) => c.category_name)
|
||||
).toContain('After reset');
|
||||
});
|
||||
|
||||
it('ignores the abandoned request after switching away and back to the same portal', async () => {
|
||||
const old = deferred();
|
||||
request.mockReturnValueOnce(old.promise);
|
||||
store.selectPlaylist('a');
|
||||
await settle();
|
||||
store.selectPlaylist('b');
|
||||
await settle();
|
||||
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'Fresh A' }] });
|
||||
store.selectPlaylist('a');
|
||||
await settle();
|
||||
old.resolve({ js: [{ id: '1', title: 'Obsolete A' }] });
|
||||
await settle();
|
||||
expect(
|
||||
store.getCategoryResource().map((c) => c.category_name)
|
||||
).toContain('Fresh A');
|
||||
});
|
||||
|
||||
it('loads the new portal categories even when the previous portal cache is populated', async () => {
|
||||
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'Portal A' }] });
|
||||
store.selectPlaylist('a');
|
||||
await settle();
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(store.isCategoryResourceFailed()).toBeNull();
|
||||
expect(
|
||||
store.getCategoryResource().map((c) => c.category_name)
|
||||
).toContain('Portal A');
|
||||
|
||||
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'Portal B' }] });
|
||||
store.selectPlaylist('b');
|
||||
expect(
|
||||
store.getCategoryResource().map((c) => c.category_name)
|
||||
).not.toContain('Portal A');
|
||||
await settle();
|
||||
expect(
|
||||
store.getCategoryResource().map((c) => c.category_name)
|
||||
).toContain('Portal B');
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(['success', 'failure'] as const)(
|
||||
'ignores a late category %s from the previous portal',
|
||||
async (outcome) => {
|
||||
const old = deferred();
|
||||
request.mockReturnValueOnce(old.promise);
|
||||
store.selectPlaylist('a');
|
||||
await settle();
|
||||
request.mockResolvedValueOnce({
|
||||
js: [{ id: '1', title: 'Portal B' }],
|
||||
});
|
||||
store.selectPlaylist('b');
|
||||
await settle();
|
||||
if (outcome === 'success')
|
||||
old.resolve({ js: [{ id: '1', title: 'Portal A' }] });
|
||||
else old.reject(new Error('Old portal offline'));
|
||||
await settle();
|
||||
expect(
|
||||
store.getCategoryResource().map((c) => c.category_name)
|
||||
).toContain('Portal B');
|
||||
expect(store.isCategoryResourceFailed()).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not reuse another section cache from the previous portal', async () => {
|
||||
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'A movies' }] });
|
||||
store.selectPlaylist('a');
|
||||
await settle();
|
||||
store.selectType('series');
|
||||
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'A series' }] });
|
||||
await settle();
|
||||
store.selectPlaylist('b');
|
||||
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'B series' }] });
|
||||
await settle();
|
||||
store.selectType('vod');
|
||||
request.mockResolvedValueOnce({ js: [{ id: '1', title: 'B movies' }] });
|
||||
await settle();
|
||||
expect(
|
||||
store.getCategoryResource().map((c) => c.category_name)
|
||||
).toContain('B movies');
|
||||
});
|
||||
});
|
||||
+17
-17
@@ -204,11 +204,11 @@ describe('withStalkerContent failure states', () => {
|
||||
: pending.promise
|
||||
);
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('itv', [
|
||||
{ category_id: '5', category_name: 'Five' },
|
||||
]);
|
||||
store.setSelectedCategory('5');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
patchState(store, { searchPhrase: 'sidebar' });
|
||||
await flushResources();
|
||||
const orderedCalls = () =>
|
||||
@@ -305,6 +305,7 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('itv', [
|
||||
{
|
||||
category_id: '5',
|
||||
@@ -312,7 +313,6 @@ describe('withStalkerContent failure states', () => {
|
||||
},
|
||||
]);
|
||||
store.setSelectedCategory('5');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(
|
||||
@@ -361,6 +361,7 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('itv', [
|
||||
{
|
||||
category_id: '5',
|
||||
@@ -368,7 +369,6 @@ describe('withStalkerContent failure states', () => {
|
||||
},
|
||||
]);
|
||||
store.setSelectedCategory('5');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.itvChannels().length === 1);
|
||||
@@ -424,11 +424,11 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('itv', [
|
||||
{ category_id: '5', category_name: 'News' },
|
||||
{ category_id: '9', category_name: 'Sports' },
|
||||
]);
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
expect(store.itvChannelsCategory()).toBeNull();
|
||||
|
||||
store.setSelectedCategory('5');
|
||||
@@ -460,10 +460,10 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('itv', [
|
||||
{ category_id: '5', category_name: 'News' },
|
||||
]);
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setSelectedCategory('5');
|
||||
void store.isPaginatedContentLoading();
|
||||
await waitForCondition(() => store.itvChannelsCategory() === '5');
|
||||
@@ -487,11 +487,11 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('itv', [
|
||||
{ category_id: '5', category_name: 'News' },
|
||||
{ category_id: '9', category_name: 'Sports' },
|
||||
]);
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setSelectedCategory('5');
|
||||
void store.isPaginatedContentLoading();
|
||||
await waitForCondition(() => store.itvChannelsCategory() === '5');
|
||||
@@ -537,11 +537,11 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
]);
|
||||
store.setSelectedCategory('5');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 2);
|
||||
@@ -587,11 +587,11 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
]);
|
||||
store.setSelectedCategory('5');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 2);
|
||||
@@ -634,12 +634,12 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
{ category_id: '9', category_name: 'Adult' },
|
||||
]);
|
||||
store.setSelectedCategory('*');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 1);
|
||||
@@ -678,12 +678,12 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
{ category_id: '9', category_name: 'Adult' },
|
||||
]);
|
||||
store.setSelectedCategory('*');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 2);
|
||||
@@ -728,12 +728,12 @@ describe('withStalkerContent failure states', () => {
|
||||
})
|
||||
);
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
{ category_id: '9', category_name: 'Adult' },
|
||||
]);
|
||||
store.setSelectedCategory('*');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 2);
|
||||
|
||||
@@ -774,11 +774,11 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
]);
|
||||
store.setSelectedCategory('5');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 1);
|
||||
|
||||
@@ -868,6 +868,7 @@ describe('withStalkerContent failure states', () => {
|
||||
});
|
||||
|
||||
store.setSelectedContentType('radio');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('radio', [
|
||||
{
|
||||
category_id: 'radio-all',
|
||||
@@ -875,7 +876,6 @@ describe('withStalkerContent failure states', () => {
|
||||
},
|
||||
]);
|
||||
store.setSelectedCategory('radio-all');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.radioChannels().length === 1);
|
||||
@@ -915,6 +915,7 @@ describe('withStalkerContent failure states', () => {
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('vod', [
|
||||
{
|
||||
category_id: '5',
|
||||
@@ -922,7 +923,6 @@ describe('withStalkerContent failure states', () => {
|
||||
},
|
||||
]);
|
||||
store.setSelectedCategory('5');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => pendingByPage.get(1)?.length === 1);
|
||||
@@ -1010,12 +1010,12 @@ describe('withStalkerContent full ITV channel list cache', () => {
|
||||
|
||||
function enterItvCategory(categoryId: string) {
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('itv', [
|
||||
{ category_id: '5', category_name: 'News' },
|
||||
{ category_id: '9', category_name: 'Sports' },
|
||||
]);
|
||||
store.setSelectedCategory(categoryId);
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
}
|
||||
|
||||
@@ -1073,12 +1073,12 @@ describe('withStalkerContent full ITV channel list cache', () => {
|
||||
|
||||
// Genre '19' has no channels in the cached full list.
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('itv', [
|
||||
{ category_id: '5', category_name: 'News' },
|
||||
{ category_id: '19', category_name: 'For adults', censored: true },
|
||||
]);
|
||||
store.setSelectedCategory('19');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.itvChannels().length === 1);
|
||||
@@ -1115,12 +1115,12 @@ describe('withStalkerContent full ITV channel list cache', () => {
|
||||
it('omits genres without cached channels from the count map (adult genres)', () => {
|
||||
setup(CACHED_CHANNELS);
|
||||
store.setSelectedContentType('itv');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
store.setCategories('itv', [
|
||||
{ category_id: '*', category_name: 'All' },
|
||||
{ category_id: '5', category_name: 'News' },
|
||||
{ category_id: '19', category_name: 'For adults', censored: true },
|
||||
]);
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
|
||||
const counts = store.itvCategoryItemCounts();
|
||||
expect(counts.get(5)).toBe(2);
|
||||
|
||||
+56
-9
@@ -64,6 +64,7 @@ function withheldStalkerCategoryIds(
|
||||
* Content/categories/channels feature state.
|
||||
*/
|
||||
export interface StalkerContentState {
|
||||
categoryPlaylistKey: string | null;
|
||||
totalCount: number;
|
||||
vodCategories: StalkerCategoryItem[];
|
||||
seriesCategories: StalkerCategoryItem[];
|
||||
@@ -96,6 +97,7 @@ export interface StalkerContentState {
|
||||
}
|
||||
|
||||
const initialContentState: StalkerContentState = {
|
||||
categoryPlaylistKey: null,
|
||||
totalCount: 0,
|
||||
vodCategories: [],
|
||||
seriesCategories: [],
|
||||
@@ -134,9 +136,13 @@ interface StalkerContentResourceStoreContract extends StalkerContentFeatureStore
|
||||
}
|
||||
|
||||
function getCategoriesByType(
|
||||
store: StalkerCategorySliceContract,
|
||||
contentType: StalkerContentType
|
||||
store: StalkerCategorySliceContract & {
|
||||
categoryPlaylistKey(): string | null;
|
||||
},
|
||||
contentType: StalkerContentType,
|
||||
playlistKey: string | null
|
||||
): StalkerCategoryItem[] {
|
||||
if (store.categoryPlaylistKey() !== playlistKey) return [];
|
||||
switch (contentType) {
|
||||
case 'vod':
|
||||
return store.vodCategories();
|
||||
@@ -304,7 +310,26 @@ export function withStalkerContent() {
|
||||
}),
|
||||
loader: async ({
|
||||
params,
|
||||
abortSignal,
|
||||
}): Promise<StalkerCategoryItem[]> => {
|
||||
const playlistKey = stalkerPlaylistKey(
|
||||
params.currentPlaylist
|
||||
);
|
||||
if (store.categoryPlaylistKey() !== playlistKey) {
|
||||
patchState(store, {
|
||||
categoryPlaylistKey: playlistKey,
|
||||
vodCategories: [],
|
||||
seriesCategories: [],
|
||||
itvCategories: [],
|
||||
radioCategories: [],
|
||||
categoryError: null,
|
||||
});
|
||||
}
|
||||
const isCurrent = () =>
|
||||
!abortSignal.aborted &&
|
||||
stalkerPlaylistKey(
|
||||
storeContext.currentPlaylist()
|
||||
) === playlistKey;
|
||||
if (!params.currentPlaylist) {
|
||||
patchState(store, { categoryError: null });
|
||||
return [];
|
||||
@@ -312,7 +337,8 @@ export function withStalkerContent() {
|
||||
|
||||
const cachedCategories = getCategoriesByType(
|
||||
store,
|
||||
params.contentType
|
||||
params.contentType,
|
||||
playlistKey
|
||||
);
|
||||
if (cachedCategories.length > 0) {
|
||||
patchState(store, { categoryError: null });
|
||||
@@ -332,6 +358,7 @@ export function withStalkerContent() {
|
||||
}
|
||||
);
|
||||
|
||||
if (!isCurrent()) return [];
|
||||
if (!Array.isArray(response?.js)) {
|
||||
const invalidResponseError = new Error(
|
||||
'Invalid categories response'
|
||||
@@ -391,6 +418,7 @@ export function withStalkerContent() {
|
||||
|
||||
return categories;
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return [];
|
||||
logger.warn('Error loading categories', {
|
||||
contentType: params.contentType,
|
||||
error,
|
||||
@@ -442,7 +470,10 @@ export function withStalkerContent() {
|
||||
: 0,
|
||||
availableCategoryCount: getCategoriesByType(
|
||||
store,
|
||||
storeContext.selectedContentType()
|
||||
storeContext.selectedContentType(),
|
||||
stalkerPlaylistKey(
|
||||
storeContext.currentPlaylist()
|
||||
)
|
||||
).filter(
|
||||
(category) =>
|
||||
String(category.category_id) !== '*'
|
||||
@@ -945,7 +976,11 @@ export function withStalkerContent() {
|
||||
storeContext.currentPlaylist(),
|
||||
contentType
|
||||
);
|
||||
const categories = getCategoriesByType(store, contentType);
|
||||
const categories = getCategoriesByType(
|
||||
store,
|
||||
contentType,
|
||||
stalkerPlaylistKey(storeContext.currentPlaylist())
|
||||
);
|
||||
return withheld.size === 0
|
||||
? categories
|
||||
: categories.filter(
|
||||
@@ -1070,7 +1105,11 @@ export function withStalkerContent() {
|
||||
}
|
||||
|
||||
const contentType = storeContext.selectedContentType();
|
||||
const categories = getCategoriesByType(store, contentType);
|
||||
const categories = getCategoriesByType(
|
||||
store,
|
||||
contentType,
|
||||
stalkerPlaylistKey(storeContext.currentPlaylist())
|
||||
);
|
||||
|
||||
return (
|
||||
categories.find(
|
||||
@@ -1093,7 +1132,8 @@ export function withStalkerContent() {
|
||||
|
||||
const category = getCategoriesByType(
|
||||
store,
|
||||
storeContext.selectedContentType()
|
||||
storeContext.selectedContentType(),
|
||||
stalkerPlaylistKey(storeContext.currentPlaylist())
|
||||
).find(
|
||||
(item) =>
|
||||
String(item.category_id) ===
|
||||
@@ -1112,7 +1152,8 @@ export function withStalkerContent() {
|
||||
getAllCategoriesForSelectedType: computed(() =>
|
||||
getCategoriesByType(
|
||||
store,
|
||||
storeContext.selectedContentType()
|
||||
storeContext.selectedContentType(),
|
||||
stalkerPlaylistKey(storeContext.currentPlaylist())
|
||||
)
|
||||
),
|
||||
isCategoryResourceLoading: computed(() =>
|
||||
@@ -1166,7 +1207,12 @@ export function withStalkerContent() {
|
||||
type: StalkerContentType,
|
||||
categories: StalkerCategoryItem[]
|
||||
) {
|
||||
patchState(store, buildCategoryPatch(type, categories));
|
||||
patchState(store, {
|
||||
categoryPlaylistKey: stalkerPlaylistKey(
|
||||
storeContext.currentPlaylist()
|
||||
),
|
||||
...buildCategoryPatch(type, categories),
|
||||
});
|
||||
},
|
||||
resetCategories() {
|
||||
patchState(store, {
|
||||
@@ -1176,6 +1222,7 @@ export function withStalkerContent() {
|
||||
radioCategories: [],
|
||||
categoryError: null,
|
||||
});
|
||||
storeContext.categoryResource.reload();
|
||||
},
|
||||
setItvChannels(channels: StalkerItvChannel[]) {
|
||||
patchState(store, {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerCatalogFacadeService } from './stalker-catalog-facade.service';
|
||||
import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
|
||||
|
||||
describe('StalkerCatalogFacadeService', () => {
|
||||
const playlist = {
|
||||
@@ -101,6 +102,10 @@ describe('StalkerCatalogFacadeService', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StalkerCatalogFacadeService,
|
||||
{
|
||||
provide: StalkerWorkspaceRouteSession,
|
||||
useValue: { isReady: signal(true) },
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: stalkerStoreMock,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
resolvePortalWatchState,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
|
||||
|
||||
@Injectable()
|
||||
export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
|
||||
@@ -52,6 +53,7 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
|
||||
private positionsLoadGeneration = 0;
|
||||
|
||||
readonly provider = 'stalker' as const;
|
||||
readonly routeReady = inject(StalkerWorkspaceRouteSession).isReady;
|
||||
readonly contentType = this.stalkerStore.selectedContentType;
|
||||
readonly selectedCategory = this.stalkerStore.getSelectedCategory;
|
||||
readonly paginatedContent = computed(
|
||||
|
||||
@@ -281,6 +281,26 @@ describe('StalkerCollectionDetailComponent', () => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('does not repoint the portal after a pending collection load outlives its view', async () => {
|
||||
const pending = new Subject<Playlist>();
|
||||
playlistsService.getPlaylistById.mockReturnValue(pending);
|
||||
fixture.componentRef.setInput('item', buildCollectionItem({}));
|
||||
fixture.detectChanges();
|
||||
TestBed.tick();
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalled();
|
||||
|
||||
fixture.destroy();
|
||||
const destination = { ...playlist, _id: 'destination' };
|
||||
await stalkerStore.setCurrentPlaylist(destination);
|
||||
stalkerStore.setSelectedItem({ id: 'destination-item' });
|
||||
pending.next(playlist);
|
||||
pending.complete();
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
|
||||
expect(currentPlaylist()).toBe(destination);
|
||||
expect(selectedItem()).toEqual({ id: 'destination-item' });
|
||||
});
|
||||
|
||||
it('opens legacy VOD is_series favorites through the lazy VOD-series flow', async () => {
|
||||
fixture.componentRef.setInput(
|
||||
'item',
|
||||
|
||||
@@ -258,6 +258,7 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
++this.initRequestId;
|
||||
restoreStalkerCollectionStoreState(
|
||||
this.stalkerStore,
|
||||
this.originalState
|
||||
|
||||
+67
-3
@@ -1,6 +1,11 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import {
|
||||
NavigationCancel,
|
||||
NavigationEnd,
|
||||
NavigationStart,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
import { EMPTY, Observable, Subject, of } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
@@ -49,8 +54,11 @@ function getStalkerSectionFromUrl(url: string): string | null {
|
||||
}
|
||||
|
||||
describe('StalkerWorkspaceRouteSession', () => {
|
||||
const routerEvents = new Subject<NavigationEnd>();
|
||||
const routerEvents = new Subject<
|
||||
NavigationStart | NavigationEnd | NavigationCancel
|
||||
>();
|
||||
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
|
||||
const currentPlaylist = signal<PlaylistMeta | undefined>(undefined);
|
||||
const selectedContentType = signal<'vod' | 'itv' | 'series' | 'radio'>(
|
||||
'vod'
|
||||
);
|
||||
@@ -61,10 +69,15 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
};
|
||||
|
||||
const stalkerStore = {
|
||||
currentPlaylist,
|
||||
resetCategories: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
clearSelectedItem: jest.fn(),
|
||||
setCurrentPlaylist: jest.fn().mockResolvedValue(undefined),
|
||||
setCurrentPlaylist: jest.fn(
|
||||
async (playlist: PlaylistMeta | undefined) => {
|
||||
currentPlaylist.set(playlist);
|
||||
}
|
||||
),
|
||||
setSelectedContentType: jest.fn(
|
||||
(type: 'vod' | 'itv' | 'series' | 'radio') => {
|
||||
selectedContentType.set(type);
|
||||
@@ -85,6 +98,7 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
beforeEach(async () => {
|
||||
router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`;
|
||||
activePlaylist.set(ACTIVE_PLAYLIST);
|
||||
currentPlaylist.set(undefined);
|
||||
selectedContentType.set('vod');
|
||||
|
||||
playlistContext.syncFromUrl.mockImplementation((url: string) => ({
|
||||
@@ -155,6 +169,56 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reconciles a revisited route when collection detail changed the shared store', async () => {
|
||||
const session = TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
currentPlaylist.set(OTHER_PLAYLIST);
|
||||
routerEvents.next(new NavigationEnd(1, router.url, router.url));
|
||||
await flushEffects();
|
||||
expect(currentPlaylist()?._id).toBe(PLAYLIST_ID);
|
||||
expect(session.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('withholds readiness during navigation and restores it after cancellation', async () => {
|
||||
const session = TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
expect(session.isReady()).toBe(true);
|
||||
routerEvents.next(
|
||||
new NavigationStart(1, '/workspace/stalker/stalker-2/vod')
|
||||
);
|
||||
expect(session.isReady()).toBe(false);
|
||||
routerEvents.next(
|
||||
new NavigationCancel(
|
||||
1,
|
||||
'/workspace/stalker/stalker-2/vod',
|
||||
'cancelled'
|
||||
)
|
||||
);
|
||||
await flushEffects();
|
||||
expect(session.isReady()).toBe(true);
|
||||
expect(currentPlaylist()?._id).toBe(PLAYLIST_ID);
|
||||
});
|
||||
|
||||
it('does not install a pending playlist after navigation leaves Stalker', async () => {
|
||||
const pending = new Subject<PlaylistMeta>();
|
||||
playlistsService.getPlaylistById.mockReturnValue(pending);
|
||||
const session = TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
router.url = '/workspace/global-recent';
|
||||
playlistContext.syncFromUrl.mockReturnValue({
|
||||
provider: null,
|
||||
playlistId: null,
|
||||
section: null,
|
||||
});
|
||||
routerEvents.next(new NavigationEnd(1, router.url, router.url));
|
||||
currentPlaylist.set(OTHER_PLAYLIST);
|
||||
pending.next(FULL_STALKER_PLAYLIST);
|
||||
pending.complete();
|
||||
await flushEffects();
|
||||
expect(currentPlaylist()).toBe(OTHER_PLAYLIST);
|
||||
expect(session.isReady()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the radio route selection after playlist bootstrap', async () => {
|
||||
router.url = `/workspace/stalker/${PLAYLIST_ID}/radio`;
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import {
|
||||
NavigationCancel,
|
||||
NavigationEnd,
|
||||
NavigationError,
|
||||
NavigationStart,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PortalRailSection } from '@iptvnator/portal/shared/util';
|
||||
@@ -27,6 +33,7 @@ export class StalkerWorkspaceRouteSession {
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
|
||||
private currentPlaylistId: string | null = null;
|
||||
private targetPlaylistId: string | null = null;
|
||||
private readonly currentSection = signal<PortalRailSection | null>(null);
|
||||
private readonly synced = signal(false);
|
||||
|
||||
@@ -56,17 +63,25 @@ export class StalkerWorkspaceRouteSession {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter(
|
||||
(event): event is NavigationEnd =>
|
||||
event instanceof NavigationEnd
|
||||
(event) =>
|
||||
event instanceof NavigationStart ||
|
||||
event instanceof NavigationEnd ||
|
||||
event instanceof NavigationCancel ||
|
||||
event instanceof NavigationError
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(() => {
|
||||
.subscribe((event) => {
|
||||
// Synchronous, and before the async sync: this session's
|
||||
// subscription is registered from an ENVIRONMENT_INITIALIZER
|
||||
// when the route injector is created, so it runs ahead of the
|
||||
// components that read `isReady`.
|
||||
this.synced.set(false);
|
||||
if (event instanceof NavigationStart) {
|
||||
this.targetPlaylistId = null;
|
||||
++this.syncGeneration;
|
||||
return;
|
||||
}
|
||||
void this.syncRouteContext();
|
||||
});
|
||||
|
||||
@@ -85,6 +100,10 @@ export class StalkerWorkspaceRouteSession {
|
||||
// playlist context, and deferring it onto the queue would leave every
|
||||
// consumer of that context a tick behind the navigation.
|
||||
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
|
||||
this.targetPlaylistId =
|
||||
routeContext.provider === 'stalker'
|
||||
? routeContext.playlistId
|
||||
: null;
|
||||
const run = async (): Promise<void> => {
|
||||
try {
|
||||
await this.applyRouteContext(generation, routeContext);
|
||||
@@ -109,17 +128,35 @@ export class StalkerWorkspaceRouteSession {
|
||||
? routeContext.playlistId
|
||||
: null;
|
||||
|
||||
if (playlistId && this.currentPlaylistId !== playlistId) {
|
||||
if (
|
||||
!playlistId ||
|
||||
this.targetPlaylistId !== playlistId ||
|
||||
this.destroyRef.destroyed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.currentPlaylistId !== playlistId ||
|
||||
this.stalkerStore.currentPlaylist()?._id !== playlistId
|
||||
) {
|
||||
this.stalkerStore.resetCategories();
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
|
||||
const playlist = await this.resolveStalkerPlaylist(playlistId);
|
||||
if (
|
||||
this.targetPlaylistId !== playlistId ||
|
||||
this.destroyRef.destroyed ||
|
||||
playlist?._id !== playlistId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.stalkerStore.setCurrentPlaylist(playlist);
|
||||
this.currentPlaylistId = playlistId;
|
||||
}
|
||||
|
||||
if (generation !== this.syncGeneration) {
|
||||
if (generation !== this.syncGeneration || this.destroyRef.destroyed) {
|
||||
// A newer arrival is queued behind this one and owns readiness.
|
||||
return;
|
||||
}
|
||||
@@ -166,7 +203,7 @@ export class StalkerWorkspaceRouteSession {
|
||||
const activePlaylist = this.playlistContext.activePlaylist();
|
||||
|
||||
if (this.hasExplicitStalkerPortalMode(playlistId, activePlaylist)) {
|
||||
return activePlaylist;
|
||||
return activePlaylist ?? undefined;
|
||||
}
|
||||
|
||||
const storedPlaylist = await firstValueFrom(
|
||||
@@ -174,13 +211,17 @@ export class StalkerWorkspaceRouteSession {
|
||||
{ defaultValue: null }
|
||||
);
|
||||
|
||||
return storedPlaylist ?? activePlaylist ?? undefined;
|
||||
return storedPlaylist?._id === playlistId
|
||||
? storedPlaylist
|
||||
: activePlaylist?._id === playlistId
|
||||
? activePlaylist
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private hasExplicitStalkerPortalMode(
|
||||
playlistId: string,
|
||||
playlist: PlaylistMeta | null
|
||||
): playlist is PlaylistMeta {
|
||||
): boolean {
|
||||
return (
|
||||
playlist?._id === playlistId &&
|
||||
playlist.isFullStalkerPortal !== undefined
|
||||
|
||||
+111
-1
@@ -33,18 +33,30 @@ const SERVER_EPOCH = 1_788_723_000;
|
||||
|
||||
const TestPortalStore = signalStore(withPortal());
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe('withPortal', () => {
|
||||
let store: InstanceType<typeof TestPortalStore>;
|
||||
let apiService: {
|
||||
getAccountInfo: jest.Mock;
|
||||
};
|
||||
let rememberServerTimezone: jest.Mock;
|
||||
let getPlaylist: jest.Mock;
|
||||
let createPlaylist: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = {
|
||||
getAccountInfo: jest.fn(),
|
||||
};
|
||||
rememberServerTimezone = jest.fn().mockResolvedValue(undefined);
|
||||
getPlaylist = jest.fn();
|
||||
createPlaylist = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -56,7 +68,8 @@ describe('withPortal', () => {
|
||||
{
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
useValue: {
|
||||
getPlaylist: jest.fn(),
|
||||
getPlaylist,
|
||||
createPlaylist,
|
||||
rememberServerTimezone,
|
||||
},
|
||||
},
|
||||
@@ -67,6 +80,103 @@ describe('withPortal', () => {
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
});
|
||||
|
||||
describe('playlist loading', () => {
|
||||
beforeEach(() => {
|
||||
store.setPlaylistId(PLAYLIST.id);
|
||||
});
|
||||
|
||||
it('refreshes the selected playlist from storage while its owner is unchanged', async () => {
|
||||
const refreshed = { ...PLAYLIST, name: 'Renamed portal' };
|
||||
getPlaylist.mockResolvedValue(refreshed);
|
||||
|
||||
await store.fetchPlaylist();
|
||||
|
||||
expect(store.currentPlaylist()).toEqual(refreshed);
|
||||
});
|
||||
|
||||
it('ignores a playlist response after switching to another portal', async () => {
|
||||
const answer = deferred<XtreamPlaylistData>();
|
||||
getPlaylist.mockReturnValue(answer.promise);
|
||||
const pending = store.fetchPlaylist();
|
||||
const other = { ...PLAYLIST, id: 'playlist-2' };
|
||||
store.setPlaylistId(other.id);
|
||||
store.setCurrentPlaylist(other);
|
||||
|
||||
answer.resolve(PLAYLIST);
|
||||
await pending;
|
||||
|
||||
expect(store.playlistId()).toBe(other.id);
|
||||
expect(store.currentPlaylist()).toEqual(other);
|
||||
});
|
||||
|
||||
it.each(['setCurrentPlaylist', 'updatePlaylist'] as const)(
|
||||
'keeps same-id connection edits made with %s while a playlist load is pending',
|
||||
async (updateMethod) => {
|
||||
const answer = deferred<XtreamPlaylistData>();
|
||||
getPlaylist.mockReturnValue(answer.promise);
|
||||
const pending = store.fetchPlaylist();
|
||||
const edited = {
|
||||
...PLAYLIST,
|
||||
serverUrl: 'https://moved.example.com',
|
||||
userAgent: 'New player',
|
||||
};
|
||||
store[updateMethod](edited);
|
||||
|
||||
answer.resolve(PLAYLIST);
|
||||
await pending;
|
||||
|
||||
expect(store.currentPlaylist()).toEqual(edited);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not repopulate a reset portal from a pending playlist load', async () => {
|
||||
const answer = deferred<XtreamPlaylistData>();
|
||||
getPlaylist.mockReturnValue(answer.promise);
|
||||
const pending = store.fetchPlaylist();
|
||||
store.resetPortal();
|
||||
|
||||
answer.resolve(PLAYLIST);
|
||||
await pending;
|
||||
|
||||
expect(store.playlistId()).toBeNull();
|
||||
expect(store.currentPlaylist()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a newer refresh when two reads of the same playlist finish out of order', async () => {
|
||||
const first = deferred<XtreamPlaylistData>();
|
||||
const second = deferred<XtreamPlaylistData>();
|
||||
getPlaylist
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
const firstPending = store.fetchPlaylist();
|
||||
const secondPending = store.fetchPlaylist();
|
||||
const refreshed = { ...PLAYLIST, name: 'Renamed portal' };
|
||||
second.resolve(refreshed);
|
||||
await secondPending;
|
||||
first.resolve(PLAYLIST);
|
||||
await firstPending;
|
||||
|
||||
expect(store.currentPlaylist()).toEqual(refreshed);
|
||||
});
|
||||
|
||||
it('does not restore the old portal after pending playlist creation completes', async () => {
|
||||
const creation = deferred<void>();
|
||||
getPlaylist.mockResolvedValue(null);
|
||||
createPlaylist.mockReturnValue(creation.promise);
|
||||
const pending = store.fetchPlaylist();
|
||||
await Promise.resolve();
|
||||
expect(createPlaylist).toHaveBeenCalledWith(PLAYLIST);
|
||||
const other = { ...PLAYLIST, id: 'playlist-2' };
|
||||
store.setPlaylistId(other.id);
|
||||
store.setCurrentPlaylist(other);
|
||||
|
||||
creation.resolve();
|
||||
await pending;
|
||||
|
||||
expect(store.currentPlaylist()).toEqual(other);
|
||||
});
|
||||
});
|
||||
|
||||
function respondWith(serverInfo: Record<string, unknown> | undefined) {
|
||||
apiService.getAccountInfo.mockResolvedValue({
|
||||
user_info: {
|
||||
|
||||
@@ -54,6 +54,9 @@ export function withPortal() {
|
||||
withMethods((store) => {
|
||||
const apiService = inject(XtreamApiService);
|
||||
const dataSource = inject(XTREAM_DATA_SOURCE);
|
||||
// Explicit selection/metadata writes and newer reads supersede
|
||||
// pending storage reads, even when the playlist id is unchanged.
|
||||
let playlistRequestId = 0;
|
||||
|
||||
/**
|
||||
* Whether a playlist (the store's current one, or the stored
|
||||
@@ -108,6 +111,9 @@ export function withPortal() {
|
||||
* Set the current playlist ID
|
||||
*/
|
||||
setPlaylistId(playlistId: string): void {
|
||||
if (store.playlistId() !== playlistId) {
|
||||
playlistRequestId++;
|
||||
}
|
||||
patchState(store, { playlistId });
|
||||
},
|
||||
|
||||
@@ -119,10 +125,14 @@ export function withPortal() {
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
const requestId = ++playlistRequestId;
|
||||
|
||||
try {
|
||||
const playlist =
|
||||
await dataSource.getPlaylist(playlistId);
|
||||
if (requestId !== playlistRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playlist) {
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
@@ -138,6 +148,9 @@ export function withPortal() {
|
||||
}
|
||||
|
||||
await dataSource.createPlaylist(currentPlaylist);
|
||||
if (requestId !== playlistRequestId) {
|
||||
return;
|
||||
}
|
||||
patchState(store, { currentPlaylist });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching playlist', error);
|
||||
@@ -145,6 +158,7 @@ export function withPortal() {
|
||||
},
|
||||
|
||||
setCurrentPlaylist(playlist: XtreamPlaylistData | null): void {
|
||||
playlistRequestId++;
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
},
|
||||
|
||||
@@ -243,6 +257,7 @@ export function withPortal() {
|
||||
updatePlaylist(updates: Partial<XtreamPlaylistData>): void {
|
||||
const current = store.currentPlaylist();
|
||||
if (current) {
|
||||
playlistRequestId++;
|
||||
patchState(store, {
|
||||
currentPlaylist: { ...current, ...updates },
|
||||
});
|
||||
@@ -253,6 +268,7 @@ export function withPortal() {
|
||||
* Reset portal state
|
||||
*/
|
||||
resetPortal(): void {
|
||||
playlistRequestId++;
|
||||
patchState(store, initialPortalState);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
flushMicrotasks,
|
||||
} from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { VIEW_IN_PORTAL_HANDOFF } from '@iptvnator/ui/components';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
@@ -9,7 +14,7 @@ import {
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { Subject, firstValueFrom, of } from 'rxjs';
|
||||
import { SerialDetailsComponent } from './serial-details/serial-details.component';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details/serial-details-resume-target.token';
|
||||
import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component';
|
||||
@@ -17,9 +22,7 @@ import { XtreamCollectionDetailComponent } from './xtream-collection-detail.comp
|
||||
describe('XtreamCollectionDetailComponent', () => {
|
||||
let fixture: ComponentFixture<XtreamCollectionDetailComponent>;
|
||||
let playlistId: ReturnType<typeof signal<string>>;
|
||||
let currentPlaylist: ReturnType<
|
||||
typeof signal<XtreamPlaylistData | null>
|
||||
>;
|
||||
let currentPlaylist: ReturnType<typeof signal<XtreamPlaylistData | null>>;
|
||||
let selectedContentType: ReturnType<
|
||||
typeof signal<'live' | 'vod' | 'series'>
|
||||
>;
|
||||
@@ -66,9 +69,8 @@ describe('XtreamCollectionDetailComponent', () => {
|
||||
(value: 'live' | 'vod' | 'series') =>
|
||||
selectedContentType.set(value)
|
||||
),
|
||||
setSelectedCategory: jest.fn(
|
||||
(value: number | null) =>
|
||||
selectedCategoryId.set(value)
|
||||
setSelectedCategory: jest.fn((value: number | null) =>
|
||||
selectedCategoryId.set(value)
|
||||
),
|
||||
setSelectedItem: jest.fn((value: unknown) =>
|
||||
selectedItem.set(value)
|
||||
@@ -178,6 +180,53 @@ describe('XtreamCollectionDetailComponent', () => {
|
||||
expect(cancelDetailsRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores a pending playlist load after the collection detail is destroyed', fakeAsync(() => {
|
||||
const pendingPlaylist = new Subject<Playlist>();
|
||||
jest.spyOn(
|
||||
TestBed.inject(PlaylistsService),
|
||||
'getPlaylistById'
|
||||
).mockReturnValue(pendingPlaylist);
|
||||
fixture.componentRef.setInput('item', {
|
||||
uid: 'xtream::xtream-1::movie:99',
|
||||
name: 'Movie One',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream Portal',
|
||||
xtreamId: 99,
|
||||
categoryId: 42,
|
||||
} satisfies UnifiedCollectionItem);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.destroy();
|
||||
const nextPlaylist: XtreamPlaylistData = {
|
||||
id: 'xtream-2',
|
||||
name: 'Next Portal',
|
||||
serverUrl: 'http://next.example',
|
||||
username: 'next-user',
|
||||
password: 'next-pass',
|
||||
type: 'xtream',
|
||||
};
|
||||
playlistId.set(nextPlaylist.id);
|
||||
currentPlaylist.set(nextPlaylist);
|
||||
selectedContentType.set('live');
|
||||
|
||||
pendingPlaylist.next({
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream Portal',
|
||||
serverUrl: 'http://xtream.example',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
} as Playlist);
|
||||
flushMicrotasks();
|
||||
|
||||
expect(playlistId()).toBe('xtream-2');
|
||||
expect(currentPlaylist()).toBe(nextPlaylist);
|
||||
expect(selectedContentType()).toBe('live');
|
||||
expect(fixture.componentInstance.detailComponent()).toBeNull();
|
||||
expect(fixture.componentInstance.detailInjector()).toBeNull();
|
||||
}));
|
||||
|
||||
it('provides itself as the view-in-portal handoff to the inline detail', async () => {
|
||||
fixture.componentRef.setInput('item', {
|
||||
uid: 'xtream::xtream-1::movie:99',
|
||||
|
||||
@@ -117,6 +117,7 @@ export class XtreamCollectionDetailComponent implements ViewInPortalHandoff {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.initRequestId++;
|
||||
this.xtreamStore.cancelDetailsRequest();
|
||||
this.restoreStoreState();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user