fix(settings): withhold the Xtream catalog at relock time, keep exact M3U titles in backups, roll back a failed relock-timeout save

- A relock now fails closed immediately: the selected detail is stepped
  off against the lock store, the catalog lists and stored search results
  are emptied, and the filtered reloads publish only while the captured
  lock version is still current.
- Backups carry M3U lock titles verbatim (exact dedup), since the locks
  match group titles exactly.
- A relock-timeout write that fails reverts the in-memory value and shows
  the settings save-failure snackbar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 06:21:18 +02:00
1 parent 559ec5508c
commit 02f5b09201
12 files changed
+282 -18

No files matched your search

@@ -18,7 +18,9 @@ describe('ParentalLockEnforcementService', () => {
const lockedStalkerIds = new Set<string>();
const parentalLock = {
version: signal(0),
active: signal(false),
registerBusyProbe: jest.fn(),
isXtreamCategoryLocked: jest.fn(() => false),
isStalkerCategoryLocked: jest.fn(
(_playlistId: string, _type: string, id: unknown) =>
id !== null &&
@@ -45,7 +47,12 @@ describe('ParentalLockEnforcementService', () => {
reloadCategories: jest.fn(async () => undefined),
reloadCachedContent: jest.fn(async () => undefined),
refreshSearchResults: jest.fn(async () => undefined),
getCategoriesBySelectedType: jest.fn(() => [{ id: 7 }, { id: 8 }]),
withholdCatalog: jest.fn(),
clearSearchResults: jest.fn(),
getCategoriesBySelectedType: jest.fn(() => [
{ id: 7, xtream_id: 70 },
{ id: 8, xtream_id: 80 },
]),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
@@ -55,6 +62,8 @@ describe('ParentalLockEnforcementService', () => {
jest.clearAllMocks();
lockedStalkerIds.clear();
router.url = '/';
parentalLock.active.set(false);
parentalLock.isXtreamCategoryLocked.mockReturnValue(false);
stalkerStore.selectedCategoryId.set('*');
stalkerStore.selectedItem.set(null);
xtreamStore.selectedCategoryId.set(null);
@@ -164,6 +173,59 @@ describe('ParentalLockEnforcementService', () => {
]);
});
it('withholds the catalog and a locked detail before the reload on relock', 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');
});
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(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'vod',
]);
});
it('does not withhold on unlock, 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();
const guard = xtreamStore.reloadCategories.mock.calls[0][0] as
(() => boolean) | undefined;
expect(guard?.()).toBe(true);
parentalLock.version.set(parentalLock.version() + 1);
expect(guard?.()).toBe(false);
});
it('re-runs the stored in-portal search after the reload', async () => {
router.url = '/workspace/xtreams/xtream-1/search';
@@ -224,6 +286,8 @@ describe('ParentalLockEnforcementService apply serialization', () => {
provide: ParentalLockService,
useValue: {
version,
active: signal(true),
isXtreamCategoryLocked: jest.fn(() => false),
registerBusyProbe: jest.fn(),
isStalkerCategoryLocked: jest.fn(() => false),
isM3uGroupLocked: jest.fn(() => false),
@@ -91,19 +91,31 @@ export class ParentalLockEnforcementService {
if (!playlistId) {
return;
}
await this.xtreamStore.reloadCategories();
await this.xtreamStore.reloadCachedContent();
if (this.parentalLock.version() !== version) {
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()) {
return;
}
// 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 (this.parentalLock.version() !== version) {
if (!shouldPublish()) {
return;
}
const match = XTREAM_ROUTE.exec(this.router.url);
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
const categories = this.xtreamStore.getCategoriesBySelectedType();
const isVisibleCategory = (categoryId: unknown): boolean =>
@@ -154,6 +166,56 @@ export class ParentalLockEnforcementService {
}
}
/**
* Clears a selected Xtream item whose category the lock store already
* says is locked. Electron rows carry the SQLite category row id; the
* category list still on screen maps it to the provider id the store is
* keyed by, the PWA carries the provider id directly. An item that
* cannot be placed is left to the post-reload check.
*/
private stepOffLockedXtreamSelection(
playlistId: string,
match: RegExpExecArray | null
): void {
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
const selectedItem = this.xtreamStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const categoryId = Number(selectedItem?.category_id);
if (!categoryType || !selectedItem || !Number.isFinite(categoryId)) {
return;
}
const category = this.xtreamStore
.getCategoriesBySelectedType()
.find(
(candidate) =>
Number((candidate as { id?: number }).id) === categoryId ||
Number(
(candidate as { category_id?: string }).category_id
) === categoryId
) as { xtream_id?: number; category_id?: string } | undefined;
const providerId = Number(category?.xtream_id ?? category?.category_id);
if (
!Number.isFinite(providerId) ||
!this.parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
providerId
)
) {
return;
}
this.xtreamStore.setSelectedItem(null);
if (match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'xtreams',
match[1],
match[2],
]);
}
}
private async applyStalker(): Promise<void> {
const playlist = this.stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
@@ -54,7 +54,11 @@ export class SettingsParentalLockFacade {
}
async setRelockMinutes(minutes: ParentalLockRelockMinutes): Promise<void> {
await this.run(() => this.parentalLock.setRelockMinutes(minutes));
await this.run(async () => {
if (!(await this.parentalLock.setRelockMinutes(minutes))) {
this.snackbar.storageFailure('save');
}
});
}
lockNow(): void {
+17 -2
View File
@@ -151,7 +151,14 @@ on either side.
in-portal search (`XtreamStore.refreshSearchResults`, the last
`searchContent` call as issued) — `searchResults` is a separate array the
search page renders directly and would otherwise keep locked titles until
the query changes. Both reloads fail closed: a category reload that
the query changes. A RELOCK fails closed at once rather than after the
database answers: 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
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. Both reloads fail closed: a category reload that
rejects empties the three category lists, and a per-type content reload
that rejects empties that type and sets it back to `idle` so the next
visit loads it again (filtered) — rows read under the previous lock state
@@ -246,7 +253,10 @@ on either side.
its candidates through the capability-selected data source
(`IXtreamDataSource.getAllCategories`, which the PWA source answers from
its session cache or the API), so PWA users can set locks too; the
hide/show checkboxes remain Electron-only. The M3U dialog hands its lock
hide/show checkboxes remain Electron-only. The relock timeout persists
through the same undo-on-failure pattern as the switch
(`setRelockMinutes` reverts the in-memory value and the facade shows the
settings save-failure snackbar). The M3U dialog hands its lock
list back to `ChannelListContainerComponent`, which awaits the write and
reports a failed save in a snackbar (the dialog has closed by then). On
Electron the `categories.locked` re-stamp (`setCategoryLocks`) clears and
@@ -268,6 +278,11 @@ on either side.
## Backup
M3U group titles travel verbatim (`normalizeParentalLockGroupTitles`, exact
dedup): locks match `channel.group.title` exactly, so the trimming
`uniqueStrings` used for favorites would weaken a lock on a title with
surrounding whitespace.
`lockedGroupTitles` (M3U), `lockedCategories` (Xtream `{categoryType,
xtreamId}`, Stalker `{categoryType, categoryId}`) travel in each entry's
`userState`, written only when the playlist has locks. Absent means "no
@@ -82,6 +82,27 @@ describe('withContent parental-lock reloads', () => {
afterEach(() => localStorage.clear());
it('withholds every catalog list at once and drops reads whose guard says no', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
await store.initializeContent();
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
store.withholdCatalog();
expect(store.liveCategories()).toEqual([]);
expect(store.vodStreams()).toEqual([]);
expect(store.serialStreams()).toEqual([]);
expect(store.contentLoadStateByType().live).toBe('ready');
await store.reloadCategories(() => false);
await store.reloadCachedContent(() => false);
expect(store.liveCategories()).toEqual([]);
expect(store.liveStreams()).toEqual([]);
await store.reloadCachedContent();
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
});
it('empties the category lists when their reload fails', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
await store.reloadCategories();
@@ -1461,7 +1461,32 @@ export function withContent() {
/**
* Reload categories from database (after visibility changes)
*/
async reloadCategories(): Promise<void> {
/**
* Empties every catalog list at once — the parental lock
* relocking must not leave rows read while unlocked on
* screen for the duration of the filtered reload. Load
* states and import bookkeeping are untouched; the reload
* refills the lists.
*/
withholdCatalog(): void {
patchState(store, {
liveCategories: [],
vodCategories: [],
serialCategories: [],
liveStreams: [],
vodStreams: [],
serialStreams: [],
});
},
/**
* @param shouldPublish answered right before each state
* patch; false drops the read (the caller's lock version
* moved on while it was in flight).
*/
async reloadCategories(
shouldPublish: () => boolean = () => true
): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
@@ -1484,6 +1509,7 @@ export function withContent() {
),
]);
if (!shouldPublish()) return;
patchState(store, {
liveCategories: live,
vodCategories: vod,
@@ -1496,6 +1522,7 @@ export function withContent() {
// keep locked category names visible. They are
// rebuilt by the next category load.
logger.error('Error reloading categories', error);
if (!shouldPublish()) return;
patchState(store, {
liveCategories: [],
vodCategories: [],
@@ -1511,7 +1538,9 @@ export function withContent() {
* the PWA data source filter locked categories at read time,
* so the in-memory catalog must be rebuilt from them.
*/
async reloadCachedContent(): Promise<void> {
async reloadCachedContent(
shouldPublish: () => boolean = () => true
): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx || !store.isContentInitialized()) {
return;
@@ -1530,9 +1559,11 @@ export function withContent() {
ctx.credentials,
'live'
)) as XtreamLiveStream[];
if (!shouldPublish()) return;
patchState(store, { liveStreams: live });
} catch (error) {
logger.error('Error reloading live streams', error);
if (!shouldPublish()) return;
patchState(store, { liveStreams: [] });
failed.push('live');
}
@@ -1544,12 +1575,14 @@ export function withContent() {
ctx.credentials,
'movie'
)) as XtreamVodStream[];
if (!shouldPublish()) return;
patchState(store, {
vodStreams: vod,
vodStreamsPlaylistId: ctx.playlistId,
});
} catch (error) {
logger.error('Error reloading VOD streams', error);
if (!shouldPublish()) return;
patchState(store, { vodStreams: [] });
failed.push('vod');
}
@@ -1561,9 +1594,11 @@ export function withContent() {
ctx.credentials,
'series'
)) as XtreamSerieItem[];
if (!shouldPublish()) return;
patchState(store, { serialStreams: series });
} catch (error) {
logger.error('Error reloading series', error);
if (!shouldPublish()) return;
patchState(store, { serialStreams: [] });
failed.push('series');
}
@@ -149,6 +149,18 @@ describe('withSearch refreshSearchResults', () => {
expect(store.searchResults()).toEqual([]);
});
it('clears stored results without forgetting the last search', async () => {
await store.searchContent('news', ['live']);
expect(store.searchResults()).toHaveLength(1);
store.clearSearchResults();
expect(store.searchResults()).toEqual([]);
await store.refreshSearchResults();
expect(searchContent).toHaveBeenCalledTimes(2);
expect(store.searchResults()).toHaveLength(1);
});
it('does nothing without a previous search or after a reset', async () => {
await store.refreshSearchResults();
expect(searchContent).not.toHaveBeenCalled();
@@ -161,6 +161,11 @@ export function withSearch() {
}
},
/** Drops the stored results; term, filters and the last search stay. */
clearSearchResults(): void {
patchState(store, { searchResults: [] });
},
/**
* Re-runs the last in-portal search with its own parameters,
* so stored results reflect the current read filters (the
@@ -226,6 +226,21 @@ describe('ParentalLockService', () => {
expect(updateBridgeSettings).not.toHaveBeenCalled();
});
it('rolls the relock timeout back when it cannot be persisted', async () => {
updateSettings.mockImplementationOnce(async () => {
parentalLockRelockMinutes.set(30);
throw new Error('QuotaExceededError');
});
const service = await createService();
await expect(service.setRelockMinutes(30)).resolves.toBe(false);
expect(service.relockMinutes()).toBe(15);
expect(updateSettings).toHaveBeenLastCalledWith({
parentalLockRelockMinutes: 15,
});
});
it('rolls the switch back when the Electron mirror cannot be written', async () => {
prompt.requestPin.mockResolvedValue('1234');
updateBridgeSettings.mockRejectedValueOnce(new Error('ipc'));
@@ -330,11 +330,26 @@ export class ParentalLockService {
return pin !== null;
}
async setRelockMinutes(minutes: number): Promise<void> {
await this.settingsStore.updateSettings({
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(minutes),
});
/**
* False when the value could not be persisted; the in-memory patch
* `updateSettings` applied before the failed write is undone so the
* timer on screen never differs from the one the next launch uses.
*/
async setRelockMinutes(minutes: number): Promise<boolean> {
const previous = this.relockMinutes();
try {
await this.settingsStore.updateSettings({
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(minutes),
});
return true;
} catch (error) {
console.error('Failed to persist the relock timeout.', error);
await this.settingsStore
.updateSettings({ parentalLockRelockMinutes: previous })
.catch(() => undefined);
return false;
}
}
// -- Lock store (ParentalLockLockStore; predicates add `active`) -------
@@ -26,6 +26,7 @@ import {
XtreamBackupSourcePin,
XtreamPendingRestoreState,
createRandomId,
normalizeParentalLockGroupTitles,
normalizeParentalLockStalkerCategories,
normalizeParentalLockXtreamCategories,
ParentalLockPlaylistLocks,
@@ -1059,7 +1060,10 @@ export class PlaylistBackupService {
private optionalLockedGroupTitles(playlistId: string): {
lockedGroupTitles?: string[];
} {
const locked = this.uniqueStrings(
// Exact titles: M3U locks match `channel.group.title` verbatim, so
// the trimming `uniqueStrings` would turn a lock on " Adult " into
// one on "Adult" and weaken the restored lock.
const locked = normalizeParentalLockGroupTitles(
this.parentalLock.locksFor(playlistId).m3u
);
return locked.length > 0 ? { lockedGroupTitles: locked } : {};
@@ -1080,7 +1084,10 @@ export class PlaylistBackupService {
if (entry.portalType === 'm3u') {
const titles = entry.userState.lockedGroupTitles;
if (Array.isArray(titles)) {
next = { ...current, m3u: this.uniqueStrings(titles) };
next = {
...current,
m3u: normalizeParentalLockGroupTitles(titles),
};
}
} else if (entry.portalType === 'xtream') {
const categories = entry.userState.lockedCategories;
@@ -1,5 +1,6 @@
import {
isParentalLockPlaylistLocksEmpty,
normalizeParentalLockGroupTitles,
isWellFormedParentalLockStore,
lockedStalkerCategoryIds,
lockedXtreamCategoryIds,
@@ -147,3 +148,11 @@ describe('isWellFormedParentalLockStore', () => {
expect(isWellFormedParentalLockStore(value)).toBe(false);
});
});
describe('normalizeParentalLockGroupTitles', () => {
it('deduplicates exactly, keeping surrounding whitespace', () => {
expect(
normalizeParentalLockGroupTitles([' Adult ', ' Adult ', 'Adult', 7])
).toEqual([' Adult ', 'Adult']);
});
});