fix(settings): drop parental-lock Xtream reloads once the playlist is switched

A reload issued for playlist A no longer publishes into the shared Xtream
store after the user opened playlist B: the store's reloads guard on the
playlist they read for, and the enforcement apply retires its search
refresh and selection checks on a playlist switch as on a newer lock
version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 12:44:13 +02:00
1 parent 501e196456
commit 451b373b1d
6 files changed
+94 -6

No files matched your search

@@ -313,6 +313,34 @@ describe('ParentalLockEnforcementService', () => {
expect(guard?.()).toBe(false);
});
it('abandons the reloads and checks once the Xtream playlist is switched', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/7';
parentalLock.active.set(true);
xtreamStore.selectedCategoryId.set(7);
parentalLock.isXtreamCategoryLocked.mockReturnValue(true);
let guard: () => boolean = () => true;
(xtreamStore.reloadCategories as jest.Mock).mockImplementationOnce(
async (shouldPublish: () => boolean) => {
guard = shouldPublish;
xtreamStore.playlistId.set('xtream-2');
router.url = '/workspace/xtreams/xtream-2/vod';
}
);
try {
await service.applyXtream(parentalLock.version());
expect(guard()).toBe(false);
expect(xtreamStore.refreshSearchResults).not.toHaveBeenCalled();
expect(
xtreamDataSource.getAllCategories
).not.toHaveBeenCalled();
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
} finally {
xtreamStore.playlistId.set('xtream-1');
}
});
it('re-runs the stored in-portal search after the reload', async () => {
router.url = '/workspace/xtreams/xtream-1/search';
@@ -132,8 +132,12 @@ export class ParentalLockEnforcementService {
if (!playlistId) {
return;
}
// A playlist switch retires the apply as a newer lock version
// does: the store now holds another portal, whose own load reads
// under the current lock state.
const shouldPublish = (): boolean =>
this.parentalLock.version() === version;
this.parentalLock.version() === version &&
this.xtreamStore.playlistId?.() === playlistId;
await this.xtreamStore.reloadCategories(shouldPublish);
await this.xtreamStore.reloadCachedContent(shouldPublish);
if (!shouldPublish()) {
+5 -1
View File
@@ -189,7 +189,11 @@ on either side.
(retiring a search still in flight, which was issued under the previous
lock state) before the filtered reads refill them; both reloads take a publish guard
answered before every state patch, so a read issued under an older lock
version is dropped instead of published. The post-reload checks of the
version is dropped instead of published. The guard also turns false once
another Xtream playlist is open (the store is a singleton), and the store
itself refuses to publish a reload into a playlist it was not read for;
the post-reload search refresh and selection checks are skipped then
too. The post-reload checks of the
selected category and item decide by the LOCK STORE through the
unfiltered category rows (`IXtreamDataSource.getAllCategories`), not by
absence from the reloaded list, which also omits categories the user
@@ -243,4 +243,35 @@ describe('withContent parental-lock reloads', () => {
series: 'ready',
});
});
it('drops reloads whose playlist was switched away while they read', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'a' }]);
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
await store.initializeContent();
const categories = createDeferred<unknown[]>();
const content = createDeferred<unknown[]>();
dataSource.getCategories.mockReturnValue(categories.promise);
dataSource.getContent.mockImplementation(
(_playlistId: string, _credentials: unknown, type: ContentType) =>
type === 'live'
? content.promise
: Promise.reject(new Error('db'))
);
const reloadingCategories = store.reloadCategories();
const reloadingContent = store.reloadCachedContent();
store.switchPlaylist('playlist-b');
categories.resolve([{ category_id: 'stale' }]);
content.resolve([{ xtream_id: 99 }]);
await reloadingCategories;
await reloadingContent;
expect(store.liveCategories()).toEqual([{ category_id: 'a' }]);
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'ready',
series: 'ready',
});
});
});
@@ -114,6 +114,10 @@ export function createContentTestStore(
patchState(store, { portalStatus: status });
return status;
},
/** Simulates the user opening another playlist. */
switchPlaylist(playlistId: string): void {
patchState(store, { playlistId });
},
})),
withContent()
);
@@ -259,6 +259,15 @@ export function withContent() {
Promise<void>
>();
// A reload publishes only while its playlist is still the open
// one: the store is shared, and rows read for playlist A must
// not land in it after the user switched to playlist B.
const publishWhileCurrent =
(playlistId: string, shouldPublish: () => boolean) =>
(): boolean =>
getPortalStore().playlistId?.() === playlistId &&
shouldPublish();
const getCachedHydrationKey = (
playlistId: string,
scope?: XtreamCachedContentScope | null
@@ -1547,10 +1556,14 @@ export function withContent() {
* moved on while it was in flight).
*/
async reloadCategories(
shouldPublish: () => boolean = () => true
shouldPublishArg: () => boolean = () => true
): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
const shouldPublish = publishWhileCurrent(
ctx.playlistId,
shouldPublishArg
);
try {
const [live, vod, series] = await Promise.all([
@@ -1601,7 +1614,7 @@ export function withContent() {
* so the in-memory catalog must be rebuilt from them.
*/
async reloadCachedContent(
shouldPublish: () => boolean = () => true
shouldPublishArg: () => boolean = () => true
): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) {
@@ -1612,9 +1625,13 @@ export function withContent() {
// read under the previous lock state: withhold those
// and reload once it has settled.
reloadAfterInitialization = true;
deferredPublishGuard = shouldPublish;
deferredPublishGuard = shouldPublishArg;
return;
}
const shouldPublish = publishWhileCurrent(
ctx.playlistId,
shouldPublishArg
);
const loadStates = store.contentLoadStateByType();
// Each type on its own: one failing read must neither
// skip the remaining types nor keep its own rows, which
@@ -1673,7 +1690,7 @@ export function withContent() {
failed.push('series');
}
}
if (failed.length > 0) {
if (failed.length > 0 && shouldPublish()) {
const next = { ...store.contentLoadStateByType() };
for (const type of failed) {
next[type] = 'idle';