fix(settings): fail closed on an unreadable lock store and make lock writes reliable

- A lock store that cannot be read is no longer treated as empty: while
  the lock is active every category is withheld (renderer predicates and
  set-based filters alike) until the PIN is entered or the store reads
  again, and writes are refused meanwhile so an empty in-memory store can
  never wipe the persisted locks. The lock set now lives in its own
  ParentalLockLockStore service.
- The M3U group dialog's lock write is awaited and a failed save is
  reported in a snackbar instead of being silently dropped.
- The Electron categories.locked re-stamp clears and re-locks inside one
  transaction, so a failed restamp keeps the previous index.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 02:59:54 +02:00
1 parent 41bab4dcaa
commit c114cd04d3
16 files changed
+482 -143

No files matched your search

@@ -212,14 +212,28 @@ describe('category.operations parental lock', () => {
]);
});
it('re-stamps one playlist/type: clears everything, then locks the listed ids', async () => {
const where = jest.fn().mockResolvedValue(undefined);
function lockIndexDb() {
const run = jest.fn();
const where = jest.fn().mockReturnValue({ run });
const set = jest.fn().mockReturnValue({ where });
const update = jest.fn().mockReturnValue({ set });
const db = { update } as unknown as AppDatabase;
const transaction = jest.fn((callback: () => void) => callback());
return {
db: { update, transaction } as unknown as AppDatabase,
run,
set,
transaction,
where,
};
}
it('re-stamps one playlist/type in one transaction: clears everything, then locks the listed ids', async () => {
const { db, run, set, transaction, where } = lockIndexDb();
await setCategoryLocks(db, 'playlist-1', 'live', [5, 5, 7, 1.5]);
expect(transaction).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledTimes(2);
expect(set).toHaveBeenNthCalledWith(1, { locked: false });
expect(set).toHaveBeenNthCalledWith(2, { locked: true });
const lockScope = new SQLiteSyncDialect().sqlToQuery(
@@ -230,10 +244,7 @@ describe('category.operations parental lock', () => {
});
it('only clears when no id is locked', async () => {
const where = jest.fn().mockResolvedValue(undefined);
const set = jest.fn().mockReturnValue({ where });
const update = jest.fn().mockReturnValue({ set });
const db = { update } as unknown as AppDatabase;
const { db, set } = lockIndexDb();
await setCategoryLocks(db, 'playlist-1', 'series', []);
@@ -241,3 +252,23 @@ describe('category.operations parental lock', () => {
expect(set).toHaveBeenCalledWith({ locked: false });
});
});
describe('setCategoryLocks atomicity', () => {
it('runs both statements inside the transaction callback', async () => {
const order: string[] = [];
const run = jest.fn(() => order.push('run'));
const where = jest.fn().mockReturnValue({ run });
const set = jest.fn().mockReturnValue({ where });
const update = jest.fn().mockReturnValue({ set });
const transaction = jest.fn((callback: () => void) => {
order.push('begin');
callback();
order.push('commit');
});
const db = { update, transaction } as unknown as AppDatabase;
await setCategoryLocks(db, 'playlist-1', 'live', [5]);
expect(order).toEqual(['begin', 'run', 'run', 'commit']);
});
});
@@ -250,14 +250,22 @@ export async function setCategoryLocks(
...new Set(lockedXtreamIds.filter((id) => Number.isInteger(id))),
];
await db.update(schema.categories).set({ locked: false }).where(scope);
// One transaction: a re-stamp that fails after the clear would otherwise
// leave every category of this playlist/type unlocked while the lock
// store still lists the intended locks. `.run()` (synchronous), not
// `.execute()`: see playback-position.operations.ts.
await db.transaction(() => {
db.update(schema.categories).set({ locked: false }).where(scope).run();
if (lockedIds.length > 0) {
await db
.update(schema.categories)
.set({ locked: true })
.where(and(scope, inArray(schema.categories.xtreamId, lockedIds)));
}
if (lockedIds.length > 0) {
db.update(schema.categories)
.set({ locked: true })
.where(
and(scope, inArray(schema.categories.xtreamId, lockedIds))
)
.run();
}
});
return { success: true };
}
+21 -3
View File
@@ -102,7 +102,18 @@ then unknown rather than off — a stored PIN stands in for it (`enabled`
follows `hasPin`, so the session starts locked and one PIN entry unlocks
it), and without a PIN the renderer does not report at all, leaving the
main process on its mirrored default instead of announcing "unlocked" on
the strength of default settings.
the strength of default settings. The lock store itself fails closed the
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 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`, 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).
### In-memory catalogs
@@ -211,7 +222,12 @@ the strength of default settings.
its candidates through the capability-selected data source
(`IXtreamDataSource.getAllCategories`, which the PWA source answers from
its session cache or the API), so PWA users can set locks too; the
hide/show checkboxes remain Electron-only.
hide/show checkboxes remain Electron-only. The M3U dialog hands its lock
list back to `ChannelListContainerComponent`, which awaits the write and
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.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
@@ -240,7 +256,9 @@ predicate is `ParentalLockService.is*Locked`.
- Contracts: `libs/shared/interfaces/src/lib/parental-lock.util.ts`,
`parental-lock-pin.util.ts`, `Settings.parentalLock*`,
`PARENTAL_LOCK_SET_STATE`.
- Renderer: `libs/services/src/lib/parental-lock/`,
- Renderer: `libs/services/src/lib/parental-lock/` (`ParentalLockService`
owns the session state and PIN flows; `ParentalLockLockStore` owns the
persisted lock set, its unreadable state and the SQLite re-stamp),
`apps/web/src/app/services/parental-lock-prompt.service.ts`,
`parental-lock-enforcement.service.ts`,
`apps/web/src/app/settings/settings-parental-lock*`,
@@ -15,6 +15,7 @@ import {
resetHostConnectivityGuard,
} from '@iptvnator/services';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
import {
StalkerCategoryItem,
StalkerContentItem,
@@ -47,11 +48,14 @@ function withheldStalkerCategoryIds(
parentalLock: ParentalLockService,
playlist: PlaylistMeta | undefined,
contentType: StalkerContentType
): Set<string> {
): ReadonlySet<string> {
const playlistId = playlist?._id;
if (!playlistId || !parentalLock.active()) {
return new Set();
}
if (parentalLock.withholdsEverything?.()) {
return ALL_CATEGORIES_WITHHELD;
}
return new Set(parentalLock.lockedStalkerIds(playlistId, contentType));
}
@@ -30,6 +30,7 @@ import {
resetHostConnectivityGuard,
} from '@iptvnator/services';
import {
ALL_CATEGORIES_WITHHELD,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerPortalActions,
@@ -278,14 +279,17 @@ export class StalkerSearchComponent {
// The dedicated search route has no category guard, so it filters
// the portal's rows itself: a locked genre's title must not reach
// the grid, its detail or playback through search.
const withheldCategoryIds = this.parentalLock.active()
? new Set(
this.parentalLock.lockedStalkerIds(
playlist._id,
contentType
)
)
: new Set<string>();
const withheldCategoryIds: ReadonlySet<string> =
!this.parentalLock.active()
? new Set<string>()
: this.parentalLock.withholdsEverything?.()
? ALL_CATEGORIES_WITHHELD
: new Set(
this.parentalLock.lockedStalkerIds(
playlist._id,
contentType
)
);
const lockVersionChanged =
this.searchResultsLockVersion !== null &&
this.searchResultsLockVersion !== params.parentalLockVersion;
@@ -20,6 +20,7 @@ describe('PwaXtreamDataSource', () => {
let parentalLock: {
active: jest.Mock<boolean, []>;
lockedXtreamIds: jest.Mock<number[], [string, string]>;
withholdsEverything: jest.Mock<boolean, []>;
};
const credentials: XtreamCredentials = {
@@ -41,6 +42,7 @@ describe('PwaXtreamDataSource', () => {
parentalLock = {
active: jest.fn(() => false),
lockedXtreamIds: jest.fn(() => []),
withholdsEverything: jest.fn(() => false),
};
TestBed.configureTestingModule({
@@ -96,6 +98,27 @@ describe('PwaXtreamDataSource', () => {
expect(unlocked).toHaveLength(2);
});
it('withholds everything while the lock store cannot be read', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'Family film', category_id: '5' },
]);
parentalLock.active.mockReturnValue(true);
parentalLock.withholdsEverything = jest.fn(() => true);
const content = await dataSource.getContent(
'playlist-1',
credentials,
'movie'
);
const found = await dataSource.searchContent('playlist-1', 'family', [
'movie',
]);
expect(content).toEqual([]);
expect(found).toEqual([]);
expect(parentalLock.lockedXtreamIds).not.toHaveBeenCalled();
});
it('reports remote loading phases for API fetches but stays silent on cache hits', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'News' },
@@ -1,5 +1,6 @@
import { inject, Injectable, Injector } from '@angular/core';
import {
ALL_CATEGORIES_WITHHELD,
foldSearchText,
ContentMetadataPatch,
Playlist,
@@ -361,10 +362,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private withheldCategoryIds(
playlistId: string,
type: CategoryType | StreamType
): Set<string> {
): ReadonlySet<string> {
if (!this.parentalLock.active()) {
return new Set();
}
if (this.parentalLock.withholdsEverything?.()) {
return ALL_CATEGORIES_WITHHELD;
}
const dbType =
type === 'vod' || type === 'movie'
? 'movies'
@@ -568,12 +568,20 @@ export class DatabaseService {
}
async getAppState(key: string): Promise<string | null> {
return (await this.readAppState(key))?.value ?? null;
}
/**
* Like {@link getAppState}, but a failed read (no bridge, rejected IPC)
* comes back as `null` instead of looking like an absent key.
*/
async readAppState(key: string): Promise<{ value: string | null } | null> {
if (typeof window.electron?.dbGetAppState !== 'function') {
return null;
}
try {
return await window.electron.dbGetAppState(key);
return { value: await window.electron.dbGetAppState(key) };
} catch (error) {
console.error('Error getting app state:', error);
return null;
@@ -1,4 +1,5 @@
export * from './parental-lock-idle-timer';
export * from './parental-lock-prompt.token';
export * from './parental-lock-lock-store.service';
export * from './parental-lock-storage';
export * from './parental-lock.service';
@@ -0,0 +1,207 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import {
createEmptyParentalLockPlaylistLocks,
isParentalLockPlaylistLocksEmpty,
lockedStalkerCategoryIds,
lockedXtreamCategoryIds,
normalizeParentalLockPlaylistLocks,
ParentalLockPlaylistLocks,
ParentalLockStalkerCategoryType,
ParentalLockStore,
ParentalLockXtreamCategoryType,
} from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { ParentalLockStorageService } from './parental-lock-storage';
import {
withM3uLocks,
withStalkerLocks,
withXtreamLocks,
} from './parental-lock-store.util';
const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [
'live',
'movies',
'series',
];
/**
* The lock store: WHICH categories are locked, per playlist, persisted
* through `ParentalLockStorageService`. Knows nothing about the PIN or the
* session state — `ParentalLockService` composes it and adds `active`.
*
* A store that could not be READ is a distinct state (`unreadable`): it is
* not "nothing is locked", and no write may be built on the empty in-memory
* store while it lasts, or the persisted locks would be wiped.
*/
@Injectable({ providedIn: 'root' })
export class ParentalLockLockStore {
private readonly storage = inject(ParentalLockStorageService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly databaseService = inject(DatabaseService);
private readonly locks = signal<ParentalLockStore>({});
private readonly revisionState = signal(0);
private loading: Promise<void> | null = null;
/** The persisted store could not be read; see `ensureReadable()`. */
readonly unreadable = signal(false);
/** 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. */
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;
}
this.locks.set(locks);
});
}
return this.loading;
}
/**
* Re-reads a store that failed to read earlier. False while it still
* cannot be read.
*/
async ensureReadable(): Promise<boolean> {
await this.load();
if (!this.unreadable()) {
return true;
}
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;
}
locksFor(playlistId: string): ParentalLockPlaylistLocks {
return (
this.locks()[playlistId] ?? createEmptyParentalLockPlaylistLocks()
);
}
lockedXtreamIds(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType
): number[] {
return lockedXtreamCategoryIds(this.locks()[playlistId], categoryType);
}
lockedStalkerIds(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType
): string[] {
return lockedStalkerCategoryIds(this.locks()[playlistId], categoryType);
}
lockedGroupTitles(playlistId: string): string[] {
return this.locks()[playlistId]?.m3u ?? [];
}
async setXtreamLocks(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType,
xtreamIds: number[]
): Promise<boolean> {
const next = withXtreamLocks(
this.locksFor(playlistId),
categoryType,
xtreamIds
);
if (!(await this.persistPlaylistLocks(playlistId, next))) {
return false;
}
return this.stampXtreamLocks(playlistId, [categoryType]);
}
async setStalkerLocks(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType,
categoryIds: string[]
): Promise<boolean> {
return this.persistPlaylistLocks(
playlistId,
withStalkerLocks(
this.locksFor(playlistId),
categoryType,
categoryIds
)
);
}
async setM3uLocks(
playlistId: string,
groupTitles: string[]
): Promise<boolean> {
return this.persistPlaylistLocks(
playlistId,
withM3uLocks(this.locksFor(playlistId), groupTitles)
);
}
/** Backup restore: replaces every lock of one playlist. */
async replacePlaylistLocks(
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
if (!(await this.persistPlaylistLocks(playlistId, locks))) {
return false;
}
return this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES);
}
/**
* Re-stamps the Electron `categories.locked` index for a playlist from
* the store, e.g. after a refresh recreated the rows.
*/
async stampXtreamLocks(
playlistId: string,
categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES
): Promise<boolean> {
if (!this.runtime.supportsXtreamSqliteDataSource) {
return true;
}
let success = true;
for (const categoryType of categoryTypes) {
success =
(await this.databaseService.setCategoryLocks(
playlistId,
categoryType,
this.lockedXtreamIds(playlistId, categoryType)
)) && success;
}
return success;
}
private async persistPlaylistLocks(
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
if (!(await this.ensureReadable())) {
return false;
}
const normalized = normalizeParentalLockPlaylistLocks(locks);
const next: ParentalLockStore = { ...this.locks() };
if (isParentalLockPlaylistLocksEmpty(normalized)) {
delete next[playlistId];
} else {
next[playlistId] = normalized;
}
if (!(await this.storage.writeLocks(next))) {
return false;
}
this.locks.set(next);
this.revisionState.update((value) => value + 1);
return true;
}
}
@@ -26,7 +26,7 @@ export class ParentalLockStorageService {
}
async readPinHash(): Promise<string | null> {
const value = await this.read(PARENTAL_LOCK_PIN_KEY);
const value = (await this.read(PARENTAL_LOCK_PIN_KEY))?.value;
return value && value.trim() !== '' ? value : null;
}
@@ -34,13 +34,21 @@ export class ParentalLockStorageService {
return this.write(PARENTAL_LOCK_PIN_KEY, hash);
}
async readLocks(): Promise<ParentalLockStore> {
const raw = await this.read(PARENTAL_LOCK_STORE_KEY);
if (!raw) {
/**
* The lock store, or `null` when it could not be READ — distinct from an
* absent store, which is `{}`. A failed read must not look like "nothing
* is locked": the service withholds everything until it can read again.
*/
async readLocks(): Promise<ParentalLockStore | null> {
const result = await this.read(PARENTAL_LOCK_STORE_KEY);
if (result === null) {
return null;
}
if (!result.value) {
return {};
}
try {
return normalizeParentalLockStore(JSON.parse(raw));
return normalizeParentalLockStore(JSON.parse(result.value));
} catch {
return {};
}
@@ -53,12 +61,13 @@ export class ParentalLockStorageService {
);
}
private async read(key: string): Promise<string | null> {
/** `null` when the read itself failed; an absent key is `{ value: null }`. */
private async read(key: string): Promise<{ value: string | null } | null> {
if (this.usesAppState) {
return this.databaseService.getAppState(key);
return this.databaseService.readAppState(key);
}
try {
return localStorage.getItem(key);
return { value: localStorage.getItem(key) };
} catch {
return null;
}
@@ -153,6 +153,36 @@ describe('ParentalLockService', () => {
expect(setParentalLockState).not.toHaveBeenCalled();
});
it('withholds every category while the lock store cannot be read', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
storage.readLocks.mockResolvedValue(null);
const service = await createService();
expect(service.active()).toBe(true);
expect(service.withholdsEverything()).toBe(true);
expect(service.isXtreamCategoryLocked('p', 'live', 1)).toBe(true);
expect(service.isStalkerCategoryLocked('p', 'itv', '1')).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.
await expect(service.setM3uLocks('p', ['Adult'])).resolves.toBe(false);
expect(storage.writeLocks).not.toHaveBeenCalled();
// The store reads again: the real locks apply and writes resume.
storage.readLocks.mockResolvedValue({
p: { xtream: [], stalker: [], m3u: ['Adult'] },
});
await expect(service.setM3uLocks('p', ['Adult', 'XXX'])).resolves.toBe(
true
);
TestBed.flushEffects();
expect(service.withholdsEverything()).toBe(false);
expect(service.isM3uGroupLocked('p', 'News')).toBe(false);
expect(service.isM3uGroupLocked('p', 'XXX')).toBe(true);
});
it('starts locked with the feature on and unlocks through the prompt', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
@@ -7,23 +7,16 @@ import {
untracked,
} from '@angular/core';
import {
createEmptyParentalLockPlaylistLocks,
hashParentalLockPin,
isParentalLockPlaylistLocksEmpty,
lockedStalkerCategoryIds,
lockedXtreamCategoryIds,
normalizeParentalLockPlaylistLocks,
normalizeParentalLockRelockMinutes,
ParentalLockPlaylistLocks,
ParentalLockStalkerCategoryType,
ParentalLockStore,
ParentalLockXtreamCategoryType,
verifyParentalLockPin,
} from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { SettingsStore } from '../settings-store.service';
import { ParentalLockIdleTimer } from './parental-lock-idle-timer';
import { ParentalLockLockStore } from './parental-lock-lock-store.service';
import {
PARENTAL_LOCK_PROMPT,
ParentalLockPromptRequest,
@@ -33,17 +26,6 @@ import {
syncParentalLockStateToMainProcess,
} from './parental-lock-bridge';
import { ParentalLockStorageService } from './parental-lock-storage';
import {
withM3uLocks,
withStalkerLocks,
withXtreamLocks,
} from './parental-lock-store.util';
const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [
'live',
'movies',
'series',
];
/**
* Parental lock: hides locked categories everywhere until the PIN is
@@ -64,13 +46,11 @@ const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [
export class ParentalLockService {
private readonly settingsStore = inject(SettingsStore);
private readonly storage = inject(ParentalLockStorageService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly databaseService = inject(DatabaseService);
private readonly locks = inject(ParentalLockLockStore);
private readonly prompt = inject(PARENTAL_LOCK_PROMPT, { optional: true });
private readonly unlockedState = signal(false);
private readonly pinHash = signal<string | null>(null);
private readonly lockStore = signal<ParentalLockStore>({});
private readonly versionState = signal(0);
// The main process starts LOCKED whenever the feature is on (mirrored
// setting). Reporting our state before settings have loaded would send a
@@ -109,8 +89,20 @@ export class ParentalLockService {
readonly active = computed(() => this.enabled() && !this.unlockedState());
/** A PIN exists; enabling is only possible once this is true. */
readonly hasPin = computed(() => this.pinHash() !== null);
/**
* Locked, and the lock store could not be read: which categories are
* locked is unknown, so EVERY category is withheld — the renderer-side
* filters treat every id as locked — until the PIN is entered or the
* store reads again. Failing toward "nothing is locked" would expose the
* protected content precisely during a storage failure.
*/
readonly withholdsEverything = computed(
() => this.active() && this.locks.unreadable()
);
/** Bumps whenever `active` or the lock store changes; consumers re-query. */
readonly version = this.versionState.asReadonly();
readonly version = computed(
() => this.versionState() + this.locks.revision()
);
readonly relockMinutes = computed(() =>
normalizeParentalLockRelockMinutes(
this.settingsStore.parentalLockRelockMinutes?.()
@@ -153,13 +145,12 @@ export class ParentalLockService {
initialize(): Promise<void> {
if (!this.initialization) {
this.initialization = (async () => {
const [pinHash, locks] = await Promise.all([
const [pinHash] = await Promise.all([
this.storage.readPinHash(),
this.storage.readLocks(),
this.locks.load(),
this.settingsStore.loadSettings(),
]);
this.pinHash.set(pinHash);
this.lockStore.set(locks);
this.versionState.update((value) => value + 1);
this.settingsReady.set(true);
})().catch((error) => {
@@ -196,6 +187,9 @@ export class ParentalLockService {
if (!this.active()) {
return true;
}
// A store that failed to read at startup may read now; do not send
// the user through the PIN for categories that are not locked.
await this.locks.ensureReadable();
if (this.pendingUnlock) {
return this.pendingUnlock;
}
@@ -316,37 +310,28 @@ export class ParentalLockService {
});
}
// -- Lock store -------------------------------------------------------
// -- Lock store (ParentalLockLockStore; predicates add `active`) -------
locksFor(playlistId: string): ParentalLockPlaylistLocks {
return (
this.lockStore()[playlistId] ??
createEmptyParentalLockPlaylistLocks()
);
return this.locks.locksFor(playlistId);
}
lockedXtreamIds(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType
): number[] {
return lockedXtreamCategoryIds(
this.lockStore()[playlistId],
categoryType
);
return this.locks.lockedXtreamIds(playlistId, categoryType);
}
lockedStalkerIds(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType
): string[] {
return lockedStalkerCategoryIds(
this.lockStore()[playlistId],
categoryType
);
return this.locks.lockedStalkerIds(playlistId, categoryType);
}
lockedGroupTitles(playlistId: string): string[] {
return this.lockStore()[playlistId]?.m3u ?? [];
return this.locks.lockedGroupTitles(playlistId);
}
/** Whether the category is locked AND currently withheld. */
@@ -357,7 +342,10 @@ export class ParentalLockService {
): boolean {
return (
this.active() &&
this.lockedXtreamIds(playlistId, categoryType).includes(xtreamId)
(this.locks.unreadable() ||
this.lockedXtreamIds(playlistId, categoryType).includes(
xtreamId
))
);
}
@@ -371,16 +359,18 @@ export class ParentalLockService {
}
return (
this.active() &&
this.lockedStalkerIds(playlistId, categoryType).includes(
String(categoryId)
)
(this.locks.unreadable() ||
this.lockedStalkerIds(playlistId, categoryType).includes(
String(categoryId)
))
);
}
isM3uGroupLocked(playlistId: string, groupTitle: string): boolean {
return (
this.active() &&
this.lockedGroupTitles(playlistId).includes(groupTitle)
(this.locks.unreadable() ||
this.lockedGroupTitles(playlistId).includes(groupTitle))
);
}
@@ -389,15 +379,8 @@ export class ParentalLockService {
categoryType: ParentalLockXtreamCategoryType,
xtreamIds: number[]
): Promise<boolean> {
const next = withXtreamLocks(
this.locksFor(playlistId),
categoryType,
xtreamIds
);
if (!(await this.persistPlaylistLocks(playlistId, next))) {
return false;
}
return this.stampXtreamLocks(playlistId, [categoryType]);
await this.initialize();
return this.locks.setXtreamLocks(playlistId, categoryType, xtreamIds);
}
async setStalkerLocks(
@@ -405,13 +388,11 @@ export class ParentalLockService {
categoryType: ParentalLockStalkerCategoryType,
categoryIds: string[]
): Promise<boolean> {
return this.persistPlaylistLocks(
await this.initialize();
return this.locks.setStalkerLocks(
playlistId,
withStalkerLocks(
this.locksFor(playlistId),
categoryType,
categoryIds
)
categoryType,
categoryIds
);
}
@@ -419,10 +400,8 @@ export class ParentalLockService {
playlistId: string,
groupTitles: string[]
): Promise<boolean> {
return this.persistPlaylistLocks(
playlistId,
withM3uLocks(this.locksFor(playlistId), groupTitles)
);
await this.initialize();
return this.locks.setM3uLocks(playlistId, groupTitles);
}
/** Backup restore: replaces every lock of one playlist. */
@@ -430,53 +409,19 @@ export class ParentalLockService {
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
if (!(await this.persistPlaylistLocks(playlistId, locks))) {
return false;
}
return this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES);
await this.initialize();
return this.locks.replacePlaylistLocks(playlistId, locks);
}
/**
* Re-stamps the Electron `categories.locked` index for a playlist from
* the store, e.g. after a refresh recreated the rows.
*/
async stampXtreamLocks(
stampXtreamLocks(
playlistId: string,
categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES
categoryTypes?: readonly ParentalLockXtreamCategoryType[]
): Promise<boolean> {
if (!this.runtime.supportsXtreamSqliteDataSource) {
return true;
}
let success = true;
for (const categoryType of categoryTypes) {
success =
(await this.databaseService.setCategoryLocks(
playlistId,
categoryType,
this.lockedXtreamIds(playlistId, categoryType)
)) && success;
}
return success;
}
private async persistPlaylistLocks(
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
await this.initialize();
const normalized = normalizeParentalLockPlaylistLocks(locks);
const next: ParentalLockStore = { ...this.lockStore() };
if (isParentalLockPlaylistLocksEmpty(normalized)) {
delete next[playlistId];
} else {
next[playlistId] = normalized;
}
if (!(await this.storage.writeLocks(next))) {
return false;
}
this.lockStore.set(next);
this.versionState.update((value) => value + 1);
return true;
return this.locks.stampXtreamLocks(playlistId, categoryTypes);
}
private async storePin(pin: string): Promise<boolean> {
@@ -48,6 +48,22 @@ export const PARENTAL_LOCK_XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCat
export const PARENTAL_LOCK_STALKER_CATEGORY_TYPES: readonly ParentalLockStalkerCategoryType[] =
['itv', 'vod', 'series', 'radio'];
/**
* A "withheld categories" set that withholds EVERY category. Used while the
* lock is active but the lock store could not be read: consumers keep their
* `size`/`has` checks and simply find nothing they may show.
*/
export const ALL_CATEGORIES_WITHHELD: ReadonlySet<string> = Object.freeze(
new (class extends Set<string> {
override has(): boolean {
return true;
}
override get size(): number {
return 1;
}
})()
);
export function createEmptyParentalLockPlaylistLocks(): ParentalLockPlaylistLocks {
return { xtream: [], stalker: [], m3u: [] };
}
@@ -6,6 +6,8 @@ import {
NavigationEnd,
Router,
} from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs';
@@ -126,6 +128,11 @@ describe('ChannelListContainerComponent', () => {
await TestBed.configureTestingModule({
imports: [ChannelListContainerComponent],
providers: [
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
{
provide: EpgService,
useValue: epgService,
@@ -297,7 +304,9 @@ describe('ChannelListContainerComponent', () => {
});
epgService.getCurrentProgramsForChannels
.mockReturnValueOnce(first.asObservable())
.mockReturnValueOnce(of(new Map([['guide-news', program('Fresh')]])));
.mockReturnValueOnce(
of(new Map([['guide-news', program('Fresh')]]))
);
fixture.detectChanges();
const channels = [
@@ -19,7 +19,8 @@ import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
@@ -51,6 +52,7 @@ import {
SettingsStore,
} from '@iptvnator/services';
import {
ALL_CATEGORIES_WITHHELD,
foldSearchText,
Channel,
EpgProgram,
@@ -119,6 +121,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly epgService = inject(EpgService);
private readonly playlistsService = inject(PlaylistsService);
private readonly parentalLock = inject(ParentalLockService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly router = inject(Router);
@@ -343,6 +347,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
if (!playlistId || !this.parentalLock.active()) {
return new Set<string>();
}
if (this.parentalLock.withholdsEverything?.()) {
return ALL_CATEGORIES_WITHHELD;
}
return new Set(this.parentalLock.lockedGroupTitles(playlistId));
});
/**
@@ -585,12 +592,27 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
);
}
onLockedGroupTitlesChanged(lockedGroupTitles: string[]): void {
async onLockedGroupTitlesChanged(
lockedGroupTitles: string[]
): Promise<void> {
const playlistId = this.lockPlaylistId();
if (!playlistId) {
return;
}
void this.parentalLock.setM3uLocks(playlistId, lockedGroupTitles);
// The dialog has closed by now, so a failed write (storage full or
// unavailable, app-state IPC failure) must be reported here, or the
// user relocks believing the chosen groups are protected.
const saved = await this.parentalLock.setM3uLocks(
playlistId,
lockedGroupTitles
);
if (!saved) {
this.snackBar.open(
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED'),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
}
onHiddenGroupTitlesChanged(hiddenGroupTitles: string[]): void {