mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(portals): preserve playlist ownership during detail handoffs (#1825)
* fix(portals): preserve playlist ownership during detail handoffs * fix(portals): reload Stalker categories only for a held destination Review follow-ups (Greptile, Codex): resetCategories() reloaded the category resource, and the route session calls it on a portal switch before the destination is resolved and on teardown, so it asked the portal being left, and a failed destination lookup could let that answer repopulate the sidebar. resetCategories() now only clears; the session calls the new reloadCategories() after installing the destination, and only when a handoff had already put that playlist in the store (the owner, and so the resource params, did not change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
23 files changed
+1001
-52
No files matched your search
+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>;
|
||||
|
||||
@@ -139,6 +139,7 @@ describe('StalkerStore API compatibility smoke', () => {
|
||||
'clearSelectedItem',
|
||||
'setCategories',
|
||||
'resetCategories',
|
||||
'reloadCategories',
|
||||
'setItvChannels',
|
||||
'setSearchPhrase',
|
||||
'fetchVodSeriesEpisodes',
|
||||
|
||||
+210
@@ -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<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('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');
|
||||
});
|
||||
});
|
||||
+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);
|
||||
|
||||
+65
-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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+109
-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,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<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,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
|
||||
|
||||
+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