mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
ad5b132cda
commit
b64defb576
7 files changed
+127
-35
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
+24
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user