fix(settings): await the startup lock-index reconciliation and withhold genre-less rows when failing closed

- The lock store is readable only once the SQLite index has been re-derived
  from it, and a re-stamp that keeps failing keeps the session fail-closed,
  so catalog reads can never serve rows stamped unlocked by a stale index.
- While everything is withheld, Stalker rows without a genre are withheld
  as well (the store filter and the renderer predicate).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 05:00:45 +02:00
1 parent ad5b132cda
commit b64defb576
7 files changed
+127 -35

No files matched your search

+8 -2
View File
@@ -111,7 +111,10 @@ is a failed read too — corruption never becomes an empty store), and while the
`ParentalLockService.withholdsEverything` is true — every `is*Locked`
predicate answers true and the set-based filters (PWA Xtream, Stalker
content and search, the M3U channel list) receive
`ALL_CATEGORIES_WITHHELD`, so the whole catalog is withheld until the PIN
`ALL_CATEGORIES_WITHHELD` — under which a row WITHOUT a genre is withheld
too (`isStalkerItemWithheld`, `isStalkerCategoryLocked`), since "no genre"
must not be the one row a withheld catalog still shows — so the whole
catalog is withheld until the PIN
is entered or the store reads again (`requestUnlock` and every lock write
retry the read first, and a write is refused while it still fails, since it
would be built on an empty in-memory store and wipe the persisted locks).
@@ -250,7 +253,10 @@ the toggle never shows a state the next launch will not have.
shown as locked while Electron reads, which filter by the index alone,
still serve it); if the rollback write fails too, the playlist is
re-stamped on the next store access, and every launch re-derives the
index from the store for each playlist that has locks.
index from the store for each playlist that has locks — awaited inside
the store's `load()`, so `readable` (and with it every catalog read the
renderer gates) stays false until the index agrees with the store; a
re-stamp that keeps failing keeps the session fail-closed.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
@@ -2,6 +2,7 @@ import {
isStalkerItemWithheld,
withoutWithheldStalkerItems,
} from './stalker-parental-lock.utils';
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
describe('stalker-parental-lock.utils', () => {
const withheld = new Set(['7', '9']);
@@ -40,3 +41,26 @@ describe('stalker-parental-lock.utils', () => {
expect(withoutWithheldStalkerItems(items, 'vod', withheld)).toEqual([]);
});
});
describe('isStalkerItemWithheld in fail-closed mode', () => {
it('withholds rows without a genre only while everything is withheld', () => {
expect(isStalkerItemWithheld({}, 'itv', ALL_CATEGORIES_WITHHELD)).toBe(
true
);
expect(
isStalkerItemWithheld(
{ name: 'x' } as never,
'vod',
ALL_CATEGORIES_WITHHELD
)
).toBe(true);
expect(isStalkerItemWithheld({}, 'itv', new Set(['9']))).toBe(false);
expect(
withoutWithheldStalkerItems(
[{ tv_genre_id: '1' }, {}],
'itv',
ALL_CATEGORIES_WITHHELD
)
).toEqual([]);
});
});
@@ -1,3 +1,4 @@
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
import type { StalkerContentType } from '../stalker-store.contracts';
/** Minimal item shape the parental lock needs: the category a row belongs to. */
@@ -24,11 +25,13 @@ export function isStalkerItemWithheld(
contentType === 'itv' || contentType === 'radio'
? item.tv_genre_id
: item.category_id;
return (
categoryId !== undefined &&
categoryId !== null &&
withheldCategoryIds.has(String(categoryId))
);
if (categoryId === undefined || categoryId === null) {
// A row without a genre is visible under a normal lock set but not
// in fail-closed mode: while the locks are unknown, "no genre" must
// not become the one row the withheld catalog still shows.
return withheldCategoryIds === ALL_CATEGORIES_WITHHELD;
}
return withheldCategoryIds.has(String(categoryId));
}
export function withoutWithheldStalkerItems<T extends StalkerLockableItem>(
@@ -51,6 +51,35 @@ describe('ParentalLockLockStore', () => {
expect(store.readable()).toBe(true);
});
it('is not readable until the startup re-stamp has completed', async () => {
let resolveStamp: (ok: boolean) => void = () => undefined;
setCategoryLocks.mockImplementation(
() => new Promise<boolean>((resolve) => (resolveStamp = resolve))
);
const load = store.load();
await Promise.resolve();
await Promise.resolve();
expect(setCategoryLocks).toHaveBeenCalled();
expect(store.readable()).toBe(false);
setCategoryLocks.mockResolvedValue(true);
resolveStamp(true);
await load;
expect(store.readable()).toBe(true);
});
it('stays not readable while the startup re-stamp keeps failing', async () => {
setCategoryLocks.mockResolvedValue(false);
await store.load();
expect(store.readable()).toBe(false);
await expect(store.ensureReadable()).resolves.toBe(false);
setCategoryLocks.mockResolvedValue(true);
await expect(store.ensureReadable()).resolves.toBe(true);
expect(store.readable()).toBe(true);
});
it('re-derives the SQLite index from the store on load', async () => {
await store.load();
// ensureReadable awaits the reconcile that load() started.
@@ -92,9 +121,11 @@ describe('ParentalLockLockStore', () => {
store.setXtreamLocks('pl-1', 'live', [7, 9])
).resolves.toBe(false);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7, 9]);
expect(store.readable()).toBe(false);
await store.ensureReadable();
await expect(store.ensureReadable()).resolves.toBe(true);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7, 9]);
expect(store.readable()).toBe(true);
});
});
@@ -50,17 +50,23 @@ export class ParentalLockLockStore {
* the next store access.
*/
private readonly staleIndexPlaylists = new Set<string>();
private readonly staleIndexCount = signal(0);
/** The persisted store could not be read; see `ensureReadable()`. */
readonly unreadable = signal(false);
/**
* The store has been read and is trustworthy. False while the initial
* read is still in flight — the settings can report the feature as on
* before the locks are known, and an empty in-memory store must not
* read as "nothing is locked" in that window.
* The store has been read, is trustworthy, and (on Electron) the SQLite
* index agrees with it. False while the initial read is still in flight
* — the settings can report the feature as on before the locks are
* known, and an empty in-memory store must not read as "nothing is
* locked" in that window — and while a re-stamp is outstanding, since
* Electron reads filter by the index alone.
*/
readonly readable = computed(
() => this.loadedState() && !this.unreadable()
() =>
this.loadedState() &&
!this.unreadable() &&
this.staleIndexCount() === 0
);
/** Bumps whenever the lock set changes; consumers re-query. */
readonly revision = this.revisionState.asReadonly();
@@ -75,19 +81,24 @@ export class ParentalLockLockStore {
*/
load(): Promise<void> {
if (!this.loading) {
this.loading = this.storage.readLocks().then((locks) => {
this.loading = (async () => {
const locks = await this.storage.readLocks();
if (locks === null) {
console.error('The parental lock store could not be read.');
this.unreadable.set(true);
} else {
this.locks.set(locks);
for (const playlistId of Object.keys(locks)) {
this.staleIndexPlaylists.add(playlistId);
this.markIndexStale(playlistId);
}
// Awaited: catalog reads issued before the index agrees
// with the store would serve rows stamped unlocked, and
// nothing would reload them afterwards.
await this.reconcileXtreamIndex();
}
this.loadedState.set(true);
void this.reconcileXtreamIndex();
});
this.revisionState.update((value) => value + 1);
})();
}
return this.loading;
}
@@ -98,24 +109,29 @@ export class ParentalLockLockStore {
*/
async ensureReadable(): Promise<boolean> {
await this.load();
if (!this.unreadable()) {
await this.reconcileXtreamIndex();
return true;
if (this.unreadable()) {
const locks = await this.storage.readLocks();
if (locks === null) {
return false;
}
this.locks.set(locks);
this.unreadable.set(false);
this.revisionState.update((value) => value + 1);
}
const locks = await this.storage.readLocks();
if (locks === null) {
return false;
}
this.locks.set(locks);
this.unreadable.set(false);
this.revisionState.update((value) => value + 1);
return true;
await this.reconcileXtreamIndex();
return this.readable();
}
private markIndexStale(playlistId: string): void {
this.staleIndexPlaylists.add(playlistId);
this.staleIndexCount.set(this.staleIndexPlaylists.size);
}
/** Re-stamps every playlist whose index may lag behind the store. */
private async reconcileXtreamIndex(): Promise<void> {
if (!this.runtime.supportsXtreamSqliteDataSource) {
this.staleIndexPlaylists.clear();
this.staleIndexCount.set(0);
return;
}
for (const playlistId of [...this.staleIndexPlaylists]) {
@@ -130,6 +146,10 @@ export class ParentalLockLockStore {
);
}
}
if (this.staleIndexCount() !== this.staleIndexPlaylists.size) {
this.staleIndexCount.set(this.staleIndexPlaylists.size);
this.revisionState.update((value) => value + 1);
}
}
locksFor(playlistId: string): ParentalLockPlaylistLocks {
@@ -227,7 +247,8 @@ export class ParentalLockLockStore {
): Promise<void> {
if (!(await this.persistPlaylistLocks(playlistId, previous))) {
console.error('Failed to roll back the parental lock store.');
this.staleIndexPlaylists.add(playlistId);
this.markIndexStale(playlistId);
this.revisionState.update((value) => value + 1);
}
}
@@ -164,6 +164,9 @@ describe('ParentalLockService', () => {
expect(service.withholdsEverything()).toBe(true);
expect(service.isXtreamCategoryLocked('p', 'live', 1)).toBe(true);
expect(service.isStalkerCategoryLocked('p', 'itv', '1')).toBe(true);
expect(service.isStalkerCategoryLocked('p', 'itv', undefined)).toBe(
true
);
expect(service.isM3uGroupLocked('p', 'News')).toBe(true);
// Nothing is known about the persisted locks: a write built on the
// empty in-memory store would wipe them.
@@ -374,15 +374,19 @@ export class ParentalLockService {
categoryType: ParentalLockStalkerCategoryType,
categoryId: string | number | null | undefined
): boolean {
if (!this.active()) {
return false;
}
// Fail-closed mode withholds rows without a genre too: "unknown
// genre" is not "no locked genre" while the locks are unknown.
if (!this.locks.readable()) {
return true;
}
if (categoryId === null || categoryId === undefined) {
return false;
}
return (
this.active() &&
(!this.locks.readable() ||
this.lockedStalkerIds(playlistId, categoryType).includes(
String(categoryId)
))
return this.lockedStalkerIds(playlistId, categoryType).includes(
String(categoryId)
);
}