diff --git a/.changes/portals-playlist-handoff.md b/.changes/portals-playlist-handoff.md new file mode 100644 index 000000000..b959c39f9 --- /dev/null +++ b/.changes/portals-playlist-handoff.md @@ -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. diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 3a0bf7dfd..b7b466a49 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -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, }) => { diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index c313acb6e..56f95b84f 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -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); diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index e9aa03a65..ddac1d09a 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -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. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 8d0520070..b0314ec78 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -393,6 +393,18 @@ 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. `resetCategories()` only clears: the route session calls it on a +portal switch before the destination is resolved and on teardown, where a +request would go to the portal being left. When a handoff had already put +the destination in the store, the owner does not change and the resource +would not reload by itself, so the session calls `reloadCategories()` once +it has installed that playlist. 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, diff --git a/docs/architecture/stalker-store-api-baseline.md b/docs/architecture/stalker-store-api-baseline.md index 1b29d177f..e2041a2f4 100644 --- a/docs/architecture/stalker-store-api-baseline.md +++ b/docs/architecture/stalker-store-api-baseline.md @@ -93,6 +93,9 @@ During refactor: - `clearSelectedItem(): void` - `setCategories(type: 'vod' | 'series' | 'itv' | 'radio', categories: StalkerCategoryItem[]): void` - `resetCategories(): void` + Clears only; it starts no request (called on portal switches and route teardown). +- `reloadCategories(): void` + Refetches the current portal's categories after a reset that keeps the owner. - `setItvChannels(channels: StalkerItvChannel[]): void` - `setRadioChannels(channels: StalkerItvChannel[]): void` - `setSearchPhrase(phrase: string): void` diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index f925a698c..19bc99759 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -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'; diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 33f4e311b..c6cf9375c 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -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 diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts index d72da4e61..de5cacd3e 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -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; readonly contentType: Signal; readonly selectedCategory: Signal; readonly paginatedContent: Signal; diff --git a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts index f4868a7e8..2dd134352 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts @@ -139,6 +139,7 @@ describe('StalkerStore API compatibility smoke', () => { 'clearSelectedItem', 'setCategories', 'resetCategories', + 'reloadCategories', 'setItvChannels', 'setSearchPhrase', 'fetchVodSeriesEpisodes', diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-category-ownership.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-category-ownership.spec.ts new file mode 100644 index 000000000..ac7f7872b --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-category-ownership.spec.ts @@ -0,0 +1,210 @@ +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; + 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('sends no request on a reset alone', async () => { + request.mockResolvedValueOnce({ + js: [{ id: '1', title: 'Before reset' }], + }); + store.selectPlaylist('a'); + await settle(); + request.mockClear(); + + store.resetCategories(); + await settle(); + + expect(request).not.toHaveBeenCalled(); + }); + + it('reloads categories when the active portal cache is explicitly reloaded after a 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(); + store.reloadCategories(); + 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'); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index ac1a4e27d..9b1c85f17 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -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); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index ea8dd602e..e1f013eca 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -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 => { + 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,8 +1207,15 @@ export function withStalkerContent() { type: StalkerContentType, categories: StalkerCategoryItem[] ) { - patchState(store, buildCategoryPatch(type, categories)); + patchState(store, { + categoryPlaylistKey: stalkerPlaylistKey( + storeContext.currentPlaylist() + ), + ...buildCategoryPatch(type, categories), + }); }, + // Clears only: called on portal switches and route teardown, + // where a reload would ask the portal being left. resetCategories() { patchState(store, { vodCategories: [], @@ -1177,6 +1225,14 @@ export function withStalkerContent() { categoryError: null, }); }, + /** + * Refetches the current portal's categories. For a reset that + * keeps the owner: the resource's params do not change then, + * so it would not load on its own. + */ + reloadCategories() { + storeContext.categoryResource.reload(); + }, setItvChannels(channels: StalkerItvChannel[]) { patchState(store, { itvChannels: channels, diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts index 9913d02a9..7a9525784 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts @@ -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, diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index ebfcc5323..aaf96dcd6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -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( diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index 870b72ea2..3c394f9cb 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -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(); + 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', diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index d53a44d8a..68582ce94 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -258,6 +258,7 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff { } ngOnDestroy(): void { + ++this.initRequestId; restoreStalkerCollectionStoreState( this.stalkerStore, this.originalState diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts index 92f60e416..8f2fa89a3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts @@ -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(); + const routerEvents = new Subject< + NavigationStart | NavigationEnd | NavigationCancel + >(); const activePlaylist = signal(ACTIVE_PLAYLIST); + const currentPlaylist = signal(undefined); const selectedContentType = signal<'vod' | 'itv' | 'series' | 'radio'>( 'vod' ); @@ -61,10 +69,16 @@ describe('StalkerWorkspaceRouteSession', () => { }; const stalkerStore = { + currentPlaylist, resetCategories: jest.fn(), + reloadCategories: 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 +99,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) => ({ @@ -103,6 +118,7 @@ describe('StalkerWorkspaceRouteSession', () => { })); stalkerStore.resetCategories.mockClear(); + stalkerStore.reloadCategories.mockClear(); stalkerStore.setSelectedCategory.mockClear(); stalkerStore.clearSelectedItem.mockClear(); stalkerStore.setCurrentPlaylist.mockClear(); @@ -155,6 +171,96 @@ describe('StalkerWorkspaceRouteSession', () => { ); }); + // A switch must not ask the portal being left for categories: the new + // owner's params make the resource load on their own. + it('does not reload categories when the store switches portals', async () => { + currentPlaylist.set(OTHER_PLAYLIST); + + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + expect(stalkerStore.resetCategories).toHaveBeenCalled(); + expect(currentPlaylist()?._id).toBe(PLAYLIST_ID); + expect(stalkerStore.reloadCategories).not.toHaveBeenCalled(); + }); + + // A handoff installed the destination in the store first: the reset + // keeps the owner, so only an explicit reload brings the categories back. + it('reloads the cleared categories once a held destination is installed', async () => { + currentPlaylist.set(ACTIVE_PLAYLIST); + + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + expect(stalkerStore.reloadCategories).toHaveBeenCalledTimes(1); + expect( + stalkerStore.reloadCategories.mock.invocationCallOrder[0] + ).toBeGreaterThan( + stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0] + ); + }); + + it('does not reload categories when the route is torn down', async () => { + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + stalkerStore.reloadCategories.mockClear(); + + TestBed.resetTestingModule(); + + expect(stalkerStore.resetCategories).toHaveBeenCalled(); + expect(stalkerStore.reloadCategories).not.toHaveBeenCalled(); + }); + + 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(); + 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`; diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts index 053cdfb22..fe5ba7092 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts @@ -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(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 => { try { await this.applyRouteContext(generation, routeContext); @@ -109,17 +128,42 @@ 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 + ) { + // A handoff can install the destination in the store before this + // session does; the categories it held are cleared all the same. + const storeHeldDestination = + 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; + // The owner did not change, so the category resource keeps its + // params and would not reload the cleared categories by itself. + if (storeHeldDestination) this.stalkerStore.reloadCategories(); } - 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 +210,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 +218,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 diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts index 56db7caae..794d904a7 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts @@ -33,18 +33,30 @@ const SERVER_EPOCH = 1_788_723_000; const TestPortalStore = signalStore(withPortal()); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe('withPortal', () => { let store: InstanceType; 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(); + 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(); + 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(); + 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(); + const second = deferred(); + 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(); + 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 | undefined) { apiService.getAccountInfo.mockResolvedValue({ user_info: { diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index 69532df62..3575e496e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -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): 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); }, }; diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts index 7f4520aed..69f051266 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts @@ -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; let playlistId: ReturnType>; - let currentPlaylist: ReturnType< - typeof signal - >; + let currentPlaylist: ReturnType>; 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(); + 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', diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts index 13680d019..5a26dfb48 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts @@ -117,6 +117,7 @@ export class XtreamCollectionDetailComponent implements ViewInPortalHandoff { } ngOnDestroy(): void { + this.initRequestId++; this.xtreamStore.cancelDetailsRequest(); this.restoreStoreState(); }