mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
4f931030e1
commit
bfdfe18a9c
11 files changed
+163
-27
No files matched your search
@@ -112,13 +112,15 @@ same way: `ParentalLockStorageService.readLocks()` reports a failed read as
|
||||
`null` (distinct from an absent store, `{}`; Electron reads through
|
||||
`DatabaseService.readAppState`, which keeps a rejected IPC apart from a
|
||||
missing key, and a stored payload that does not parse or does not have the
|
||||
shape `writeLocks` produces — `isWellFormedParentalLockStore`, down to the
|
||||
nested entries — is a failed read too — corruption never becomes an empty
|
||||
shape `writeLocks` produces — `isWellFormedParentalLockStore`, all three
|
||||
lists present, down to the nested entries — is a failed read too — corruption never becomes an empty
|
||||
store), and while the lock is active with the store unreadable
|
||||
`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` — under which a row WITHOUT a genre is withheld
|
||||
`ALL_CATEGORIES_WITHHELD`, and `ElectronXtreamDataSource` serves no
|
||||
categories, content or search hits either, since its SQLite index may still
|
||||
carry a stale stamp — 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
|
||||
@@ -130,7 +132,9 @@ The window before the initial read settles is treated the same way
|
||||
the feature as on before the locks are known. The feature switch itself is
|
||||
persisted through one guarded path (`persistEnabled`): `updateSettings`
|
||||
patches memory before it writes, so a failed write is undone in memory and
|
||||
`setupPin`/`disable` report false; the Electron mirror write is awaited
|
||||
`setupPin`/`disable` report false (whether to persist is decided from the
|
||||
settings switch BEFORE the PIN is stored, since `enabled` follows `hasPin`
|
||||
while the switch is unknown); the Electron mirror write is awaited
|
||||
next, and a mirror that cannot be written undoes the settings write the
|
||||
same way — the toggle never shows a state the next launch will not have,
|
||||
on either side.
|
||||
@@ -278,7 +282,8 @@ on either side.
|
||||
write that removes a playlist's LAST lock clears the index first and
|
||||
drops the key afterwards (the reconcile finds playlists only through
|
||||
their key; an interruption then leaves store-with-lock and index-without,
|
||||
which the next reconcile repairs toward locked) — awaited inside
|
||||
which the next reconcile repairs toward locked; a store recovered by a
|
||||
later successful read marks its playlists stale the same way) — 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+38
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user