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 {