mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(settings): fail closed on relock ahead of the apply queue and judge Xtream selections by the lock store
- On relock the synchronous fail-closed steps (M3U channel, Stalker selection, the locked Xtream detail, catalog lists, stored search) run immediately instead of queueing behind an earlier apply that may still wait on a slow or hung read. - The post-reload Xtream checks decide by the lock store through the unfiltered category rows rather than by absence from the reloaded list, which also omits merely hidden categories; unreadable rows fail closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
395dd570d9
commit
acabd99107
3 files changed
+183
-105
No files matched your search
@@ -3,13 +3,17 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { ParentalLockEnforcementService } from './parental-lock-enforcement.service';
|
||||
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
|
||||
|
||||
interface Applier {
|
||||
apply(): Promise<void>;
|
||||
failClosedNow(): void;
|
||||
applyXtream(version: number): Promise<void>;
|
||||
applyStalker(): Promise<void>;
|
||||
}
|
||||
@@ -59,6 +63,14 @@ describe('ParentalLockEnforcementService', () => {
|
||||
setSelectedItem: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
};
|
||||
const xtreamDataSource = {
|
||||
getAllCategories: jest.fn(async () => [
|
||||
{ id: 7, xtream_id: 70 },
|
||||
{ id: 8, xtream_id: 80 },
|
||||
{ id: 55, xtream_id: 550 },
|
||||
{ id: 99, xtream_id: 990 },
|
||||
]),
|
||||
};
|
||||
let service: Applier;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -75,6 +87,7 @@ describe('ParentalLockEnforcementService', () => {
|
||||
providers: [
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{ provide: XtreamStore, useValue: xtreamStore },
|
||||
{ provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource },
|
||||
{ provide: StalkerStore, useValue: stalkerStore },
|
||||
{ provide: Router, useValue: router },
|
||||
{
|
||||
@@ -214,13 +227,25 @@ describe('ParentalLockEnforcementService', () => {
|
||||
});
|
||||
|
||||
describe('Xtream', () => {
|
||||
it('clears a selected item whose category is no longer readable', async () => {
|
||||
function lockProvider(providerId: number): void {
|
||||
parentalLock.active.set(true);
|
||||
parentalLock.isXtreamCategoryLocked.mockImplementation(
|
||||
(_p: string, _t: string, id: number) => id === providerId
|
||||
);
|
||||
}
|
||||
|
||||
it('clears a selected item whose category the lock store withholds', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/42';
|
||||
lockProvider(990);
|
||||
xtreamStore.selectedItem.set({ category_id: 99 });
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.reloadCategories).toHaveBeenCalled();
|
||||
expect(xtreamDataSource.getAllCategories).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
'movies'
|
||||
);
|
||||
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
|
||||
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
@@ -231,52 +256,56 @@ describe('ParentalLockEnforcementService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('withholds the catalog and a locked detail before the reload on relock', async () => {
|
||||
it('keeps a detail from a category that is merely hidden, not locked', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/42';
|
||||
parentalLock.active.set(true);
|
||||
parentalLock.isXtreamCategoryLocked.mockImplementation(
|
||||
(_p: string, _t: string, providerId: number) =>
|
||||
providerId === 70
|
||||
);
|
||||
xtreamStore.selectedItem.set({ category_id: 7 });
|
||||
const order: string[] = [];
|
||||
xtreamStore.withholdCatalog.mockImplementation(() =>
|
||||
order.push('withhold')
|
||||
);
|
||||
xtreamStore.setSelectedItem.mockImplementation(() =>
|
||||
order.push('step-off')
|
||||
);
|
||||
xtreamStore.reloadCategories.mockImplementation(async () => {
|
||||
order.push('reload');
|
||||
});
|
||||
lockProvider(990);
|
||||
// Row 55 is absent from the (hidden-filtered) visible list but
|
||||
// exists unlocked in the unfiltered rows.
|
||||
xtreamStore.selectedItem.set({ category_id: 55 });
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(order.slice(0, 3)).toEqual([
|
||||
'step-off',
|
||||
'withhold',
|
||||
'reload',
|
||||
]);
|
||||
expect(xtreamStore.clearSearchResults).toHaveBeenCalled();
|
||||
expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
'movies',
|
||||
70
|
||||
);
|
||||
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('steps off a selected locked category', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/live';
|
||||
lockProvider(990);
|
||||
xtreamStore.selectedCategoryId.set(99);
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
|
||||
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
'xtream-1',
|
||||
'vod',
|
||||
'live',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not withhold on unlock, and hands the reloads a publish guard', async () => {
|
||||
it('fails closed when the category rows cannot be read', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/42';
|
||||
parentalLock.active.set(true);
|
||||
xtreamDataSource.getAllCategories.mockRejectedValueOnce(
|
||||
new Error('db')
|
||||
);
|
||||
xtreamStore.selectedItem.set({ category_id: 7 });
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('skips the post-reload checks while unlocked and hands the reloads a publish guard', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod';
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.withholdCatalog).not.toHaveBeenCalled();
|
||||
expect(xtreamDataSource.getAllCategories).not.toHaveBeenCalled();
|
||||
const guard = xtreamStore.reloadCategories.mock.calls[0][0] as
|
||||
(() => boolean) | undefined;
|
||||
expect(guard?.()).toBe(true);
|
||||
@@ -292,31 +321,17 @@ describe('ParentalLockEnforcementService', () => {
|
||||
expect(xtreamStore.refreshSearchResults).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a selected item whose category survived the lock', async () => {
|
||||
it('fails closed synchronously on relock: detail, catalog and search', () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/42';
|
||||
xtreamStore.selectedCategoryId.set(7);
|
||||
lockProvider(70);
|
||||
xtreamStore.selectedItem.set({ category_id: 7 });
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('steps off a selected category that vanished', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/live';
|
||||
xtreamStore.selectedCategoryId.set(99);
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
service.failClosedNow();
|
||||
|
||||
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
|
||||
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
'xtream-1',
|
||||
'live',
|
||||
]);
|
||||
expect(xtreamStore.withholdCatalog).toHaveBeenCalled();
|
||||
expect(xtreamStore.clearSearchResults).toHaveBeenCalled();
|
||||
expect(xtreamStore.reloadCategories).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -334,6 +349,8 @@ describe('ParentalLockEnforcementService apply serialization', () => {
|
||||
),
|
||||
reloadCachedContent: jest.fn(async () => undefined),
|
||||
refreshSearchResults: jest.fn(async () => undefined),
|
||||
withholdCatalog: jest.fn(),
|
||||
clearSearchResults: jest.fn(),
|
||||
getCategoriesBySelectedType: jest.fn(() => [] as unknown[]),
|
||||
setSelectedItem: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
@@ -352,13 +369,22 @@ describe('ParentalLockEnforcementService apply serialization', () => {
|
||||
},
|
||||
},
|
||||
{ provide: XtreamStore, useValue: xtreamStore },
|
||||
{
|
||||
// No rows: the selected category cannot be placed and
|
||||
// fails closed once an apply gets to judge it.
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
useValue: { getAllCategories: jest.fn(async () => []) },
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: { currentPlaylist: signal(null) },
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { url: '/', navigate: jest.fn() },
|
||||
useValue: {
|
||||
url: '/workspace/xtreams/xtream-1/live',
|
||||
navigate: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
@@ -383,9 +409,13 @@ describe('ParentalLockEnforcementService apply serialization', () => {
|
||||
await Promise.resolve();
|
||||
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
|
||||
|
||||
// ...and "Lock now" arrives meanwhile: no second reload starts yet.
|
||||
// ...and "Lock now" arrives meanwhile: no second reload starts yet,
|
||||
// but the catalog is withheld at once rather than behind the hung
|
||||
// read.
|
||||
xtreamStore.withholdCatalog.mockClear();
|
||||
version.set(2);
|
||||
TestBed.flushEffects();
|
||||
expect(xtreamStore.withholdCatalog).toHaveBeenCalledTimes(1);
|
||||
await Promise.resolve();
|
||||
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -415,6 +445,7 @@ describe('ParentalLockEnforcementService busy probe', () => {
|
||||
},
|
||||
},
|
||||
{ provide: XtreamStore, useValue: {} },
|
||||
{ provide: XTREAM_DATA_SOURCE, useValue: {} },
|
||||
{ provide: StalkerStore, useValue: {} },
|
||||
{ provide: Router, useValue: { url: '/' } },
|
||||
{
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ChannelActions, selectActive } from '@iptvnator/m3u-state';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces';
|
||||
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
|
||||
@@ -29,6 +32,7 @@ export const STALKER_ROUTE =
|
||||
export class ParentalLockEnforcementService {
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
|
||||
private readonly injector = inject(EnvironmentInjector);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
@@ -57,9 +61,17 @@ export class ParentalLockEnforcementService {
|
||||
}
|
||||
const first = this.lastVersion === -1;
|
||||
this.lastVersion = version;
|
||||
if (!first) {
|
||||
this.scheduleApply();
|
||||
if (first) {
|
||||
return;
|
||||
}
|
||||
// Fail closed NOW, ahead of the serialized queue: an earlier
|
||||
// apply may still be waiting on a slow (or hung) read, and
|
||||
// the catalog, details and playback read while unlocked must
|
||||
// not stay usable until it settles.
|
||||
if (this.parentalLock.active()) {
|
||||
this.failClosedNow();
|
||||
}
|
||||
this.scheduleApply();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -82,6 +94,28 @@ export class ParentalLockEnforcementService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The synchronous half of a relock: M3U channel, Stalker selection (its
|
||||
* step is lazy, so it runs as soon as the chunk is there), the Xtream
|
||||
* detail the lock store already places in a locked category, the
|
||||
* catalog lists and the stored search. The queued apply then reloads
|
||||
* the filtered rows and repeats the checks against them.
|
||||
*/
|
||||
private failClosedNow(): void {
|
||||
this.applyM3u();
|
||||
void this.applyStalker();
|
||||
const playlistId = this.xtreamStore.playlistId?.();
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
this.stepOffLockedXtreamSelection(
|
||||
playlistId,
|
||||
XTREAM_ROUTE.exec(this.router.url)
|
||||
);
|
||||
this.xtreamStore.withholdCatalog?.();
|
||||
this.xtreamStore.clearSearchResults?.();
|
||||
}
|
||||
|
||||
private async apply(): Promise<void> {
|
||||
const version = this.parentalLock.version();
|
||||
// The synchronous surfaces first: an M3U channel or a Stalker
|
||||
@@ -100,17 +134,6 @@ export class ParentalLockEnforcementService {
|
||||
}
|
||||
const shouldPublish = (): boolean =>
|
||||
this.parentalLock.version() === version;
|
||||
const match = XTREAM_ROUTE.exec(this.router.url);
|
||||
if (this.parentalLock.active()) {
|
||||
// Relock: fail closed NOW, not after the database answers. The
|
||||
// selected detail is judged against the lock store while the
|
||||
// pre-reload category list can still map its category; the
|
||||
// catalog lists and stored search results are emptied and
|
||||
// refilled by the filtered reads below.
|
||||
this.stepOffLockedXtreamSelection(playlistId, match);
|
||||
this.xtreamStore.withholdCatalog?.();
|
||||
this.xtreamStore.clearSearchResults?.();
|
||||
}
|
||||
await this.xtreamStore.reloadCategories(shouldPublish);
|
||||
await this.xtreamStore.reloadCachedContent(shouldPublish);
|
||||
if (!shouldPublish()) {
|
||||
@@ -119,51 +142,70 @@ export class ParentalLockEnforcementService {
|
||||
// Stored in-portal search results are a separate array the search
|
||||
// page renders directly; re-run the search so it reads filtered.
|
||||
await this.xtreamStore.refreshSearchResults?.();
|
||||
if (!shouldPublish() || !this.parentalLock.active()) {
|
||||
return;
|
||||
}
|
||||
await this.stepOffWithheldXtreamSelection(playlistId, shouldPublish);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-reload check of the selected Xtream category and item, judged by
|
||||
* the LOCK STORE through the unfiltered category rows (hidden and locked
|
||||
* ones included): the reloaded list also omits categories the user
|
||||
* merely hid, which are not parental-locked. The item is judged on its
|
||||
* own category — opened from "All", recently added or search it has no
|
||||
* selected category to vanish with. Rows that cannot be read fail
|
||||
* closed.
|
||||
*/
|
||||
private async stepOffWithheldXtreamSelection(
|
||||
playlistId: string,
|
||||
shouldPublish: () => boolean
|
||||
): Promise<void> {
|
||||
const match = XTREAM_ROUTE.exec(this.router.url);
|
||||
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
|
||||
if (!categoryType) {
|
||||
return;
|
||||
}
|
||||
const rows = await this.xtreamDataSource
|
||||
.getAllCategories(playlistId, categoryType)
|
||||
.catch(() => null);
|
||||
if (!shouldPublish()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
|
||||
const categories = this.xtreamStore.getCategoriesBySelectedType();
|
||||
const isVisibleCategory = (categoryId: unknown): boolean =>
|
||||
categories.some(
|
||||
(category) =>
|
||||
Number(
|
||||
(category as { id?: number | string }).id ??
|
||||
(category as { category_id?: string }).category_id
|
||||
) === Number(categoryId)
|
||||
const isWithheld = (categoryId: unknown): boolean => {
|
||||
const id = Number(categoryId);
|
||||
if (
|
||||
categoryId === null ||
|
||||
categoryId === undefined ||
|
||||
!Number.isFinite(id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const row = rows?.find((candidate) => candidate.id === id);
|
||||
return (
|
||||
!row ||
|
||||
this.parentalLock.isXtreamCategoryLocked(
|
||||
playlistId,
|
||||
categoryType,
|
||||
row.xtream_id
|
||||
)
|
||||
);
|
||||
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
|
||||
// The selected ITEM is judged on its own category: opened from
|
||||
// "All", recently added or search it has no selected category to
|
||||
// vanish with, yet its detail must not outlive the lock.
|
||||
};
|
||||
const selectedItem = this.xtreamStore.selectedItem?.() as {
|
||||
category_id?: string | number;
|
||||
} | null;
|
||||
const itemWithheld =
|
||||
selectedItem?.category_id !== undefined &&
|
||||
selectedItem?.category_id !== null &&
|
||||
!isVisibleCategory(selectedItem.category_id);
|
||||
if (itemWithheld) {
|
||||
this.xtreamStore.setSelectedItem(null);
|
||||
}
|
||||
if (
|
||||
selectedCategoryId === null ||
|
||||
isVisibleCategory(selectedCategoryId)
|
||||
) {
|
||||
if (itemWithheld && match && match[1] === playlistId) {
|
||||
void this.router.navigate([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
match[1],
|
||||
match[2],
|
||||
]);
|
||||
}
|
||||
const itemWithheld = isWithheld(selectedItem?.category_id);
|
||||
const categoryWithheld = isWithheld(
|
||||
this.xtreamStore.selectedCategoryId()
|
||||
);
|
||||
if (!itemWithheld && !categoryWithheld) {
|
||||
return;
|
||||
}
|
||||
this.xtreamStore.setSelectedItem(null);
|
||||
this.xtreamStore.setSelectedCategory(null);
|
||||
if (match && match[1] === playlistId && categoryType) {
|
||||
if (categoryWithheld) {
|
||||
this.xtreamStore.setSelectedCategory(null);
|
||||
}
|
||||
if (match && match[1] === playlistId) {
|
||||
void this.router.navigate([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
|
||||
@@ -181,14 +181,19 @@ on either side.
|
||||
`searchContent` call as issued) — `searchResults` is a separate array the
|
||||
search page renders directly and would otherwise keep locked titles until
|
||||
the query changes. A RELOCK fails closed at once rather than after the
|
||||
database answers: the selected detail is stepped off synchronously when
|
||||
database answers — ahead of the serialized apply queue, so an earlier
|
||||
apply still waiting on a slow or hung read cannot delay it: the selected detail is stepped off synchronously when
|
||||
the lock store already names its category (the pre-reload category list
|
||||
maps Electron's row id to the provider id), then `withholdCatalog()`
|
||||
empties every catalog list and `clearSearchResults()` the stored search
|
||||
(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. A lock change that overtakes
|
||||
version is dropped instead of published. 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
|
||||
merely hid; rows that cannot be read fail closed. A lock change that overtakes
|
||||
the INITIAL hydration (the content is not initialized yet, so the reload
|
||||
has nothing to re-read) sets a deferred-reload flag instead — already
|
||||
when `withholdCatalog()` empties the lists, before the category reload is
|
||||
|
||||
Reference in new issue
Block a user