fix(settings): withhold Electron Xtream reads while locks are unknown, persist the switch when settings are unreadable, re-stamp after a recovered read

- ElectronXtreamDataSource serves no categories, content or search hits
  while the lock store withholds everything; its SQLite index may still
  carry a stale stamp.
- setupPin decides whether to persist the switch from the settings value
  before the PIN is stored, since enabled follows hasPin while the switch
  is unknown.
- A lock store recovered by a later read marks its playlists stale so the
  index is re-derived, a persisted entry must carry all three lists, and a
  stale Stalker search page is dropped before touching the withheld-id
  bookkeeping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 07:36:23 +02:00
1 parent 4f931030e1
commit bfdfe18a9c
11 files changed
+163 -27

No files matched your search

@@ -378,6 +378,13 @@ export class StalkerSearchComponent {
contentType,
withheldCategoryIds
);
// Before the withheld-id bookkeeping: a stale page must not
// pre-record ids into a set a newer relock request cleared,
// or that request's page counts no new withheld rows and
// stops paging short of later visible matches.
if (!isCurrent()) {
return items;
}
let newWithheldCount = 0;
if (items.length < rawItems.length) {
const kept = new Set(items);
@@ -393,10 +400,6 @@ export class StalkerSearchComponent {
}
}
if (!isCurrent()) {
return items;
}
const merged = this.applySearchPageSuccess(
params.page,
items,
@@ -1,3 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { ParentalLockService } from '@iptvnator/services';
import {
credentials,
ElectronXtreamDataSourceHarness,
@@ -34,6 +36,42 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
harness = setupElectronXtreamDataSource();
});
describe('withholding everything', () => {
it('serves no categories, content or search hits while the lock store withholds everything', async () => {
const parentalLock = TestBed.inject(ParentalLockService);
Object.defineProperty(parentalLock, 'withholdsEverything', {
configurable: true,
value: () => true,
});
harness.dbService.getXtreamImportStatus.mockResolvedValue(
'completed'
);
harness.dbService.getXtreamCategories.mockResolvedValue([
dbCategory,
]);
harness.dbService.getXtreamContent.mockResolvedValue([
dbContentItem,
]);
await expect(
harness.dataSource.getCategories(
playlistId,
credentials,
'live'
)
).resolves.toEqual([]);
await expect(
harness.dataSource.getContent(playlistId, credentials, 'live')
).resolves.toEqual([]);
await expect(
harness.dataSource.searchContent(playlistId, 'news', ['live'])
).resolves.toEqual([]);
expect(
harness.dbService.getXtreamCategories
).not.toHaveBeenCalled();
});
});
describe('getCategories', () => {
it('returns cached categories without calling the API when import is completed', async () => {
harness.dbService.getXtreamImportStatus.mockResolvedValue(
@@ -149,6 +149,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategoryFromDb[]> {
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
const dbType = mapCategoryTypeToDbType(type);
// The lock version keys the share: a read issued under an older lock
// state (still unlocked, or locks since edited) answers with rows the
@@ -307,6 +313,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamContentItem[]> {
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
const requestKey = `${playlistId}:${type}:${this.parentalLock.version()}`;
const inFlightRequest = this.contentRequests.get(requestKey);
@@ -424,6 +436,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
types: string[],
excludeHidden?: boolean
): Promise<XtreamContentItem[]> {
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
return this.dbService.searchXtreamContent(
playlistId,
searchTerm,
@@ -80,6 +80,18 @@ describe('ParentalLockLockStore', () => {
expect(store.readable()).toBe(true);
});
it('re-derives the index from a store recovered after a failed read', async () => {
storage.readLocks.mockResolvedValueOnce(null);
await store.load();
expect(store.readable()).toBe(false);
expect(setCategoryLocks).not.toHaveBeenCalled();
await expect(store.ensureReadable()).resolves.toBe(true);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7]);
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.
@@ -116,6 +116,12 @@ export class ParentalLockLockStore {
}
this.locks.set(locks);
this.unreadable.set(false);
// The index may carry stamps from a write that failed before
// the read did; re-derive it from the recovered store as load()
// does.
for (const playlistId of Object.keys(locks)) {
this.markIndexStale(playlistId);
}
this.revisionState.update((value) => value + 1);
}
await this.reconcileXtreamIndex();
@@ -35,7 +35,9 @@ describe('ParentalLockStorageService.readLocks', () => {
it('normalizes a stored store', async () => {
localStorage.setItem(
PARENTAL_LOCK_STORE_KEY,
JSON.stringify({ p: { m3u: ['Adult'], junk: 1 } })
JSON.stringify({
p: { xtream: [], stalker: [], m3u: ['Adult'], junk: 1 },
})
);
await expect(service.readLocks()).resolves.toEqual({
p: { xtream: [], stalker: [], m3u: ['Adult'] },
@@ -46,11 +48,18 @@ describe('ParentalLockStorageService.readLocks', () => {
['truncated JSON', '{"p":{"m3u":["Adu'],
['an array payload', '[]'],
['a scalar payload', '"locks"'],
['a corrupt nested list', '{"p":{"xtream":"corrupt"}}'],
['a corrupt nested entry', '{"p":{"m3u":[1]}}'],
[
'a corrupt nested list',
'{"p":{"xtream":"corrupt","stalker":[],"m3u":[]}}',
],
[
'a corrupt nested entry',
'{"p":{"xtream":[],"stalker":[],"m3u":[1]}}',
],
['a playlist entry missing a list', '{"p":{}}'],
[
'an entry the normalizer would drop',
'{"p":{"xtream":[{"categoryType":"live","xtreamId":"nope"}]}}',
'{"p":{"xtream":[{"categoryType":"live","xtreamId":"nope"}],"stalker":[],"m3u":[]}}',
],
])('treats %s as a failed read, not an empty store', async (_, raw) => {
localStorage.setItem(PARENTAL_LOCK_STORE_KEY, raw);
@@ -226,6 +226,21 @@ describe('ParentalLockService', () => {
expect(updateBridgeSettings).not.toHaveBeenCalled();
});
it('still persists the switch when settings could not be read', async () => {
storageFailure.set('load');
prompt.requestPin.mockResolvedValue('1234');
const service = await createService();
await expect(service.setupPin()).resolves.toBe(true);
expect(updateSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
expect(updateBridgeSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
});
it('keeps the session locked on a failed PIN read and retries before unlocking', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
@@ -238,6 +238,12 @@ export class ParentalLockService {
if (!this.prompt) {
return false;
}
// Decided BEFORE the PIN is stored: with the settings switch
// unknown, `enabled` follows `hasPin`, and would read as on the
// moment the PIN lands — skipping the persistence the next launch
// depends on.
const needsPersist =
this.settingsStore.parentalLockEnabled?.() !== true;
const pin = await this.prompt.requestPin({ mode: 'set' });
if (pin === null) {
return false;
@@ -246,7 +252,7 @@ export class ParentalLockService {
return false;
}
this.unlockedState.set(true);
if (!this.enabled() && !(await this.persistEnabled(true))) {
if (needsPersist && !(await this.persistEnabled(true))) {
this.unlockedState.set(false);
return false;
}
@@ -113,7 +113,7 @@ describe('parental-lock.util', () => {
});
describe('isWellFormedParentalLockStore', () => {
it('accepts what writeLocks produces, lists omitted or empty', () => {
it('accepts what writeLocks produces, lists present even when empty', () => {
expect(isWellFormedParentalLockStore({})).toBe(true);
expect(
isWellFormedParentalLockStore({
@@ -122,7 +122,7 @@ describe('isWellFormedParentalLockStore', () => {
stalker: [{ categoryType: 'itv', categoryId: '9' }],
m3u: ['Adult'],
},
q: { m3u: [] },
q: { xtream: [], stalker: [], m3u: [] },
})
).toBe(true);
});
@@ -130,19 +130,45 @@ describe('isWellFormedParentalLockStore', () => {
it.each([
['a non-object root', []],
['a non-object playlist entry', { p: 'corrupt' }],
['a non-list lock field', { p: { xtream: 'corrupt' } }],
['a non-string group title', { p: { m3u: [1] } }],
['a playlist entry missing a list', { p: { xtream: [], m3u: [] } }],
['an empty playlist entry', { p: {} }],
[
'a non-list lock field',
{ p: { xtream: 'corrupt', stalker: [], m3u: [] } },
],
[
'a non-string group title',
{ p: { xtream: [], stalker: [], m3u: [1] } },
],
[
'an unknown category type',
{ p: { xtream: [{ categoryType: 'x', xtreamId: 1 }] } },
{
p: {
xtream: [{ categoryType: 'x', xtreamId: 1 }],
stalker: [],
m3u: [],
},
},
],
[
'a non-numeric xtream id',
{ p: { xtream: [{ categoryType: 'live', xtreamId: 'nope' }] } },
{
p: {
xtream: [{ categoryType: 'live', xtreamId: 'nope' }],
stalker: [],
m3u: [],
},
},
],
[
'the Stalker "all" pseudo id',
{ p: { stalker: [{ categoryType: 'itv', categoryId: '*' }] } },
{
p: {
xtream: [],
stalker: [{ categoryType: 'itv', categoryId: '*' }],
m3u: [],
},
},
],
])('rejects %s', (_, value) => {
expect(isWellFormedParentalLockStore(value)).toBe(false);
@@ -156,16 +156,14 @@ function isWellFormedList(
value: unknown,
isWellFormedEntry: (entry: unknown) => boolean
): boolean {
return (
value === undefined ||
(Array.isArray(value) && value.every(isWellFormedEntry))
);
return Array.isArray(value) && value.every(isWellFormedEntry);
}
/**
* Whether a persisted store has the shape `writeLocks` produces: every
* playlist entry an object whose optional `xtream`/`stalker`/`m3u` lists
* hold only entries the normalizers accept. The normalizers DROP what they
* playlist entry an object whose `xtream`/`stalker`/`m3u` lists are all
* present (a missing list is corruption, not "empty") and hold only
* entries the normalizers accept. The normalizers DROP what they
* do not understand, which is right for user-supplied backups but turns a
* corrupted persisted store into "nothing is locked"; a store that fails
* this check is treated as unreadable instead.