fix(settings): close the startup, re-stamp, relock-refresh and switch-persistence gaps

- The window before the initial lock store read settles now withholds
  everything, like an unreadable store: settings can report the feature as
  on before the locks are known.
- The store commits before the SQLite index re-stamp; a failed re-stamp
  now rolls the store back, a failed rollback re-stamps on the next
  access, and every launch re-derives the index from the store.
- Xtream category/content reloads fail closed: a rejected reload empties
  the affected lists (content types drop back to idle) instead of keeping
  rows read under the previous lock state.
- Enabling/disabling the feature persists through one guarded path that
  undoes the in-memory switch and skips the Electron mirror on a failed
  settings write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 04:20:06 +02:00
1 parent f5c3543c28
commit 4b14c0929c
7 files changed
+355 -31

No files matched your search

+19 -2
View File
@@ -115,6 +115,13 @@ content and search, the M3U channel list) receive
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).
The window before the initial read settles is treated the same way
(`ParentalLockLockStore.readable` is false until then): settings can report
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, the
Electron mirror is left untouched and `setupPin`/`disable` report false —
the toggle never shows a state the next launch will not have.
### In-memory catalogs
@@ -137,7 +144,11 @@ would be built on an empty in-memory store and wipe the persisted locks).
in-portal search (`XtreamStore.refreshSearchResults`, the last
`searchContent` call as issued) — `searchResults` is a separate array the
search page renders directly and would otherwise keep locked titles until
the query changes. Live playback is stopped by the layout itself:
the query changes. Both reloads fail closed: a category reload that
rejects empties the three category lists, and a per-type content reload
that rejects empties that type and sets it back to `idle` so the next
visit loads it again (filtered) — rows read under the previous lock state
are never kept. Live playback is stopped by the layout itself:
`LiveStreamLayoutComponent` keeps the playing channel's provider category
id (mapped from the SQLite row id on Electron) and drops `activePlayback`
on a `version` change that locks it, because the player is gated on that
@@ -233,7 +244,13 @@ would be built on an empty in-memory store and wipe the persisted locks).
reports a failed save in a snackbar (the dialog has closed by then). On
Electron the `categories.locked` re-stamp (`setCategoryLocks`) clears and
re-locks one playlist/type inside ONE transaction, so a failed restamp
keeps the previous index instead of leaving every category unlocked.
keeps the previous index instead of leaving every category unlocked. The
store commits BEFORE that re-stamp, so a failed re-stamp rolls the store
back to what the index reflects (a category must not be recorded and
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.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
@@ -462,6 +462,46 @@ describe('withContent import state', () => {
expect(store.isContentInitialized()).toBe(true);
});
it('empties the category lists when their reload fails', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
await store.reloadCategories();
expect(store.liveCategories()).toEqual([{ category_id: 'x' }]);
dataSource.getCategories.mockRejectedValue(new Error('db'));
await store.reloadCategories();
expect(store.liveCategories()).toEqual([]);
expect(store.vodCategories()).toEqual([]);
expect(store.serialCategories()).toEqual([]);
});
it('empties a type whose cached reload fails and lets the others reload', async () => {
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
await store.initializeContent();
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'ready',
series: 'ready',
});
dataSource.getContent.mockImplementation(
(_playlistId: string, _credentials: unknown, type: ContentType) =>
type === 'movie'
? Promise.reject(new Error('db'))
: Promise.resolve([{ xtream_id: 2 }])
);
await store.reloadCachedContent();
expect(store.vodStreams()).toEqual([]);
expect(store.liveStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.serialStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'idle',
series: 'ready',
});
});
it('records the VOD category owner when categories are reloaded', async () => {
dataSource.getCategories.mockImplementation(
(
@@ -1491,7 +1491,16 @@ export function withContent() {
serialCategories: series,
});
} catch (error) {
// Fail closed: the lists on screen were read under
// the previous lock state, so keeping them would
// keep locked category names visible. They are
// rebuilt by the next category load.
logger.error('Error reloading categories', error);
patchState(store, {
liveCategories: [],
vodCategories: [],
serialCategories: [],
});
}
},
@@ -1508,16 +1517,28 @@ export function withContent() {
return;
}
const loadStates = store.contentLoadStateByType();
try {
if (loadStates.live === 'ready') {
// Each type on its own: one failing read must neither
// skip the remaining types nor keep its own rows, which
// were read under the previous lock state. A failed type
// is emptied and set back to `idle`, so the next visit
// loads it again (filtered) instead of showing a gap.
const failed: ContentType[] = [];
if (loadStates.live === 'ready') {
try {
const live = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'live'
)) as XtreamLiveStream[];
patchState(store, { liveStreams: live });
} catch (error) {
logger.error('Error reloading live streams', error);
patchState(store, { liveStreams: [] });
failed.push('live');
}
if (loadStates.vod === 'ready') {
}
if (loadStates.vod === 'ready') {
try {
const vod = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
@@ -1527,17 +1548,32 @@ export function withContent() {
vodStreams: vod,
vodStreamsPlaylistId: ctx.playlistId,
});
} catch (error) {
logger.error('Error reloading VOD streams', error);
patchState(store, { vodStreams: [] });
failed.push('vod');
}
if (loadStates.series === 'ready') {
}
if (loadStates.series === 'ready') {
try {
const series = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'series'
)) as XtreamSerieItem[];
patchState(store, { serialStreams: series });
} catch (error) {
logger.error('Error reloading series', error);
patchState(store, { serialStreams: [] });
failed.push('series');
}
} catch (error) {
logger.error('Error reloading cached content', error);
}
if (failed.length > 0) {
const next = { ...store.contentLoadStateByType() };
for (const type of failed) {
next[type] = 'idle';
}
patchState(store, { contentLoadStateByType: next });
}
},
@@ -0,0 +1,100 @@
import { TestBed } from '@angular/core/testing';
import { ParentalLockStore } from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { ParentalLockLockStore } from './parental-lock-lock-store.service';
import { ParentalLockStorageService } from './parental-lock-storage';
describe('ParentalLockLockStore', () => {
const stored: ParentalLockStore = {
'pl-1': {
xtream: [{ categoryType: 'live', xtreamId: 7 }],
stalker: [],
m3u: [],
},
};
let storage: { readLocks: jest.Mock; writeLocks: jest.Mock };
let setCategoryLocks: jest.Mock;
let store: ParentalLockLockStore;
beforeEach(() => {
storage = {
readLocks: jest.fn(async () => JSON.parse(JSON.stringify(stored))),
writeLocks: jest.fn(async () => true),
};
setCategoryLocks = jest.fn(async () => true);
TestBed.configureTestingModule({
providers: [
ParentalLockLockStore,
{ provide: ParentalLockStorageService, useValue: storage },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource: true },
},
{ provide: DatabaseService, useValue: { setCategoryLocks } },
],
});
store = TestBed.inject(ParentalLockLockStore);
});
it('is not readable until the initial read has settled', async () => {
let resolveRead: (locks: ParentalLockStore) => void = () => undefined;
storage.readLocks.mockReturnValue(
new Promise<ParentalLockStore>((resolve) => (resolveRead = resolve))
);
const load = store.load();
expect(store.readable()).toBe(false);
resolveRead({});
await load;
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.
await store.ensureReadable();
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7]);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'movies', []);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'series', []);
});
it('rolls the store back when the index re-stamp fails', async () => {
await store.load();
setCategoryLocks.mockResolvedValue(false);
await expect(
store.setXtreamLocks('pl-1', 'live', [7, 9])
).resolves.toBe(false);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
expect(storage.writeLocks).toHaveBeenLastCalledWith(
expect.objectContaining({
'pl-1': expect.objectContaining({
xtream: [{ categoryType: 'live', xtreamId: 7 }],
}),
})
);
});
it('re-stamps on the next access when even the rollback write failed', async () => {
await store.load();
await store.ensureReadable();
setCategoryLocks.mockClear();
setCategoryLocks.mockResolvedValueOnce(false);
storage.writeLocks
.mockResolvedValueOnce(true) // the new locks
.mockResolvedValueOnce(false); // the rollback
await expect(
store.setXtreamLocks('pl-1', 'live', [7, 9])
).resolves.toBe(false);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7, 9]);
await store.ensureReadable();
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7, 9]);
});
});
@@ -42,24 +42,51 @@ export class ParentalLockLockStore {
private readonly locks = signal<ParentalLockStore>({});
private readonly revisionState = signal(0);
private readonly loadedState = signal(false);
private loading: Promise<void> | null = null;
/**
* Playlists whose SQLite index could not be brought in line with the
* store (a failed re-stamp whose rollback failed too); re-stamped on
* the next store access.
*/
private readonly staleIndexPlaylists = new Set<string>();
/** 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.
*/
readonly readable = computed(
() => this.loadedState() && !this.unreadable()
);
/** Bumps whenever the lock set changes; consumers re-query. */
readonly revision = this.revisionState.asReadonly();
readonly isEmpty = computed(() => Object.keys(this.locks()).length === 0);
/** Reads the persisted store once. */
/**
* Reads the persisted store once. On Electron the `categories.locked`
* index is then re-derived from it for every playlist that has locks:
* the store is authoritative, and a re-stamp that failed in an earlier
* session (or a database restored beside a newer store) must not leave
* the index behind indefinitely.
*/
load(): Promise<void> {
if (!this.loading) {
this.loading = this.storage.readLocks().then((locks) => {
if (locks === null) {
console.error('The parental lock store could not be read.');
this.unreadable.set(true);
return;
} else {
this.locks.set(locks);
for (const playlistId of Object.keys(locks)) {
this.staleIndexPlaylists.add(playlistId);
}
}
this.locks.set(locks);
this.loadedState.set(true);
void this.reconcileXtreamIndex();
});
}
return this.loading;
@@ -72,6 +99,7 @@ export class ParentalLockLockStore {
async ensureReadable(): Promise<boolean> {
await this.load();
if (!this.unreadable()) {
await this.reconcileXtreamIndex();
return true;
}
const locks = await this.storage.readLocks();
@@ -84,6 +112,26 @@ export class ParentalLockLockStore {
return true;
}
/** Re-stamps every playlist whose index may lag behind the store. */
private async reconcileXtreamIndex(): Promise<void> {
if (!this.runtime.supportsXtreamSqliteDataSource) {
this.staleIndexPlaylists.clear();
return;
}
for (const playlistId of [...this.staleIndexPlaylists]) {
try {
if (await this.stampXtreamLocks(playlistId)) {
this.staleIndexPlaylists.delete(playlistId);
}
} catch (error) {
console.error(
'Failed to reconcile the parental lock index.',
error
);
}
}
}
locksFor(playlistId: string): ParentalLockPlaylistLocks {
return (
this.locks()[playlistId] ?? createEmptyParentalLockPlaylistLocks()
@@ -113,15 +161,16 @@ export class ParentalLockLockStore {
categoryType: ParentalLockXtreamCategoryType,
xtreamIds: number[]
): Promise<boolean> {
const next = withXtreamLocks(
this.locksFor(playlistId),
categoryType,
xtreamIds
);
const previous = this.locksFor(playlistId);
const next = withXtreamLocks(previous, categoryType, xtreamIds);
if (!(await this.persistPlaylistLocks(playlistId, next))) {
return false;
}
return this.stampXtreamLocks(playlistId, [categoryType]);
if (await this.stampXtreamLocks(playlistId, [categoryType])) {
return true;
}
await this.rollBack(playlistId, previous);
return false;
}
async setStalkerLocks(
@@ -154,10 +203,32 @@ export class ParentalLockLockStore {
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
const previous = this.locksFor(playlistId);
if (!(await this.persistPlaylistLocks(playlistId, locks))) {
return false;
}
return this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES);
if (await this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES)) {
return true;
}
await this.rollBack(playlistId, previous);
return false;
}
/**
* The store commits before the SQLite index is re-stamped; a failed
* re-stamp would otherwise leave a category recorded (and shown) as
* locked while Electron reads, which filter by the index alone, still
* serve it. The store goes back to what the index reflects; if even
* that write fails, the playlist is re-stamped on the next access.
*/
private async rollBack(
playlistId: string,
previous: ParentalLockPlaylistLocks
): Promise<void> {
if (!(await this.persistPlaylistLocks(playlistId, previous))) {
console.error('Failed to roll back the parental lock store.');
this.staleIndexPlaylists.add(playlistId);
}
}
/**
@@ -183,6 +183,46 @@ describe('ParentalLockService', () => {
expect(service.isM3uGroupLocked('p', 'XXX')).toBe(true);
});
it('withholds everything while the initial lock store read is in flight', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
let resolveLocks: (locks: Record<string, unknown>) => void = () =>
undefined;
storage.readLocks.mockReturnValue(
new Promise((resolve) => (resolveLocks = resolve))
);
const service = TestBed.inject(ParentalLockService);
const initialization = service.initialize();
await Promise.resolve();
expect(service.active()).toBe(true);
expect(service.withholdsEverything()).toBe(true);
expect(service.isM3uGroupLocked('p', 'News')).toBe(true);
resolveLocks({});
await initialization;
expect(service.withholdsEverything()).toBe(false);
expect(service.isM3uGroupLocked('p', 'News')).toBe(false);
});
it('rolls the switch back when enabling cannot be persisted', async () => {
prompt.requestPin.mockResolvedValue('1234');
updateSettings.mockImplementationOnce(async () => {
// updateSettings patches memory before the write fails.
parentalLockEnabled.set(true);
throw new Error('QuotaExceededError');
});
const service = await createService();
await expect(service.setupPin()).resolves.toBe(false);
expect(service.hasPin()).toBe(true);
expect(service.enabled()).toBe(false);
expect(service.unlocked()).toBe(false);
expect(updateBridgeSettings).not.toHaveBeenCalled();
});
it('starts locked with the feature on and unlocks through the prompt', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
@@ -97,7 +97,7 @@ export class ParentalLockService {
* protected content precisely during a storage failure.
*/
readonly withholdsEverything = computed(
() => this.active() && this.locks.unreadable()
() => this.active() && !this.locks.readable()
);
/** Bumps whenever `active` or the lock store changes; consumers re-query. */
readonly version = computed(
@@ -241,15 +241,36 @@ export class ParentalLockService {
return false;
}
this.unlockedState.set(true);
if (!this.enabled()) {
await this.settingsStore.updateSettings({
parentalLockEnabled: true,
});
mirrorParentalLockEnabledSetting(true);
if (!this.enabled() && !(await this.persistEnabled(true))) {
this.unlockedState.set(false);
return false;
}
return true;
}
/**
* Persists the feature switch. `updateSettings` patches the in-memory
* value before the write; on a failed write that patch is undone (the
* second write fails the same way and is ignored), so the toggle
* cannot show a state the next launch will not have, and the Electron
* mirror is only updated for a persisted switch.
*/
private async persistEnabled(enabled: boolean): Promise<boolean> {
try {
await this.settingsStore.updateSettings({
parentalLockEnabled: enabled,
});
} catch (error) {
console.error('Failed to persist the parental lock switch.', error);
await this.settingsStore
.updateSettings({ parentalLockEnabled: !enabled })
.catch(() => undefined);
return false;
}
mirrorParentalLockEnabledSetting(enabled);
return true;
}
/** Verifies the current PIN, then replaces it. */
async changePin(): Promise<boolean> {
await this.initialize();
@@ -274,10 +295,9 @@ export class ParentalLockService {
if (!(await this.verifyCurrentPin())) {
return false;
}
await this.settingsStore.updateSettings({
parentalLockEnabled: false,
});
mirrorParentalLockEnabledSetting(false);
if (!(await this.persistEnabled(false))) {
return false;
}
this.unlockedState.set(false);
return true;
}
@@ -342,7 +362,7 @@ export class ParentalLockService {
): boolean {
return (
this.active() &&
(this.locks.unreadable() ||
(!this.locks.readable() ||
this.lockedXtreamIds(playlistId, categoryType).includes(
xtreamId
))
@@ -359,7 +379,7 @@ export class ParentalLockService {
}
return (
this.active() &&
(this.locks.unreadable() ||
(!this.locks.readable() ||
this.lockedStalkerIds(playlistId, categoryType).includes(
String(categoryId)
))
@@ -369,7 +389,7 @@ export class ParentalLockService {
isM3uGroupLocked(playlistId: string, groupTitle: string): boolean {
return (
this.active() &&
(this.locks.unreadable() ||
(!this.locks.readable() ||
this.lockedGroupTitles(playlistId).includes(groupTitle))
);
}