mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
feat(settings): add PIN-protected parental lock for categories (#285)
Locks are per category (Xtream category ids, Stalker genre ids, M3U group titles) and kept in one renderer lock store persisted to app_state / localStorage; `categories.locked` is the SQLite index re-stamped from it. While the lock is active the DB worker filters every content read, the PWA data source, the Stalker store and the M3U channel list filter in memory, and the enforcement service reloads the stores and steps off withheld selections. Settings → Parental lock sets the PIN (PBKDF2, never in Settings), the relock timeout and Lock now; lock toggles live in the Xtream/M3U management dialogs and a new Stalker lock dialog, all behind the PIN. Backups carry the locks per playlist entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
17b8aa309d
commit
83e70a52c5
130 files changed
+6254
-278
No files matched your search
@@ -24,6 +24,8 @@ export * from './lib/ipc-command.class';
|
||||
export * from './lib/ipc-commands';
|
||||
export * from './lib/language.enum';
|
||||
export * from './lib/m3u-favorite-channel.interface';
|
||||
export * from './lib/parental-lock.util';
|
||||
export * from './lib/parental-lock-pin.util';
|
||||
export * from './lib/parsed-playlist.interface';
|
||||
export * from './lib/performance-phase.interface';
|
||||
export * from './lib/playback-position.interface';
|
||||
|
||||
@@ -482,6 +482,8 @@ export interface ElectronBridgeCategoryRow {
|
||||
type: 'movies' | 'live' | 'series';
|
||||
xtream_id: number;
|
||||
hidden: boolean;
|
||||
/** Parental lock index, stamped from the renderer's lock store. */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeXtreamContent {
|
||||
@@ -745,6 +747,14 @@ export interface ElectronBridgeApi {
|
||||
* leave the display pinned awake.
|
||||
*/
|
||||
setPlaybackKeepAwake: (active: boolean) => Promise<void>;
|
||||
/**
|
||||
* Parental lock enforcement for the SQLite worker: `true` withholds
|
||||
* every row in a locked category from content reads, `false` returns
|
||||
* them. Reset to the mirrored `parentalLockEnabled` setting when the
|
||||
* renderer reloads or dies, so a crashed page can never leave the
|
||||
* library unlocked.
|
||||
*/
|
||||
setParentalLockState: (active: boolean) => Promise<void>;
|
||||
fetchPlaylistByUrl: (
|
||||
url: string,
|
||||
title?: string,
|
||||
@@ -986,7 +996,8 @@ export interface ElectronBridgeApi {
|
||||
playlistId: string,
|
||||
categories: XtreamCategory[],
|
||||
type: string,
|
||||
hiddenCategoryXtreamIds?: number[]
|
||||
hiddenCategoryXtreamIds?: number[],
|
||||
lockedCategoryXtreamIds?: number[]
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
dbGetAllCategories: (
|
||||
playlistId: string,
|
||||
@@ -996,6 +1007,17 @@ export interface ElectronBridgeApi {
|
||||
categoryIds: number[],
|
||||
hidden: boolean
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
/**
|
||||
* Re-stamps `categories.locked` for one playlist and type from the given
|
||||
* provider category ids: rows in the list become locked, every other row
|
||||
* of that playlist/type unlocked. Idempotent, so the renderer can replay
|
||||
* its lock store after a refresh or a backup restore.
|
||||
*/
|
||||
dbSetCategoryLocks: (
|
||||
playlistId: string,
|
||||
type: string,
|
||||
lockedXtreamIds: number[]
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
dbHasContent: (playlistId: string, type: string) => Promise<boolean>;
|
||||
dbGetContent: (
|
||||
playlistId: string,
|
||||
|
||||
@@ -124,6 +124,12 @@ export const REMOTE_CONTROL_CHANGE_CHANNEL = 'REMOTE_CONTROL_CHANGE_CHANNEL';
|
||||
// the main process to hold a powerSaveBlocker so the screen stays awake
|
||||
export const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE';
|
||||
|
||||
// Parental lock: the renderer tells the main process whether locked
|
||||
// categories must currently be withheld (feature on and no PIN entered), so
|
||||
// the SQLite worker can filter every content read. Reset to the mirrored
|
||||
// `parentalLockEnabled` setting when the renderer reloads or dies.
|
||||
export const PARENTAL_LOCK_SET_STATE = 'PARENTAL_LOCK:SET_STATE';
|
||||
|
||||
// Window controls (custom title bar on Windows/Linux)
|
||||
export const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
|
||||
export const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import {
|
||||
hashParentalLockPin,
|
||||
isValidParentalLockPin,
|
||||
verifyParentalLockPin,
|
||||
} from './parental-lock-pin.util';
|
||||
|
||||
describe('parental-lock-pin.util', () => {
|
||||
beforeAll(() => {
|
||||
// The lib compiles for the browser; give Node's WebCrypto the same
|
||||
// global shape the renderer has.
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: webcrypto,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts 4 to 8 digits only', () => {
|
||||
expect(isValidParentalLockPin('1234')).toBe(true);
|
||||
expect(isValidParentalLockPin('12345678')).toBe(true);
|
||||
expect(isValidParentalLockPin('123')).toBe(false);
|
||||
expect(isValidParentalLockPin('123456789')).toBe(false);
|
||||
expect(isValidParentalLockPin('12a4')).toBe(false);
|
||||
expect(isValidParentalLockPin(1234)).toBe(false);
|
||||
});
|
||||
|
||||
it('hashes with a fresh salt and verifies the right PIN only', async () => {
|
||||
const first = await hashParentalLockPin('2468');
|
||||
const second = await hashParentalLockPin('2468');
|
||||
|
||||
expect(first).not.toEqual(second);
|
||||
expect(first.startsWith('v1$')).toBe(true);
|
||||
expect(first).not.toContain('2468');
|
||||
await expect(verifyParentalLockPin('2468', first)).resolves.toBe(true);
|
||||
await expect(verifyParentalLockPin('2468', second)).resolves.toBe(true);
|
||||
await expect(verifyParentalLockPin('2469', first)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('never verifies against a malformed or missing hash', async () => {
|
||||
await expect(verifyParentalLockPin('1234', null)).resolves.toBe(false);
|
||||
await expect(verifyParentalLockPin('1234', 'v0$x$y$z')).resolves.toBe(
|
||||
false
|
||||
);
|
||||
await expect(
|
||||
verifyParentalLockPin('1234', 'v1$abc$!!$??')
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
verifyParentalLockPin('12', 'v1$1$AA==$AA==')
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to hash an invalid PIN', async () => {
|
||||
await expect(hashParentalLockPin('12')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* PIN hashing for the parental lock.
|
||||
*
|
||||
* The PIN is a child lock, not a security boundary: the stored hash lives in a
|
||||
* user-readable file. It is still never stored in clear — PBKDF2-SHA256 with
|
||||
* a random salt through WebCrypto, which exists in both the Electron
|
||||
* renderer and the browser, so Electron and PWA share one implementation.
|
||||
*
|
||||
* Stored format: `v1$<iterations>$<salt base64>$<hash base64>`.
|
||||
*/
|
||||
|
||||
export const PARENTAL_LOCK_PIN_MIN_LENGTH = 4;
|
||||
export const PARENTAL_LOCK_PIN_MAX_LENGTH = 8;
|
||||
|
||||
const PIN_HASH_VERSION = 'v1';
|
||||
const PIN_HASH_ITERATIONS = 100_000;
|
||||
const PIN_HASH_SALT_BYTES = 16;
|
||||
const PIN_HASH_LENGTH_BITS = 256;
|
||||
|
||||
export function isValidParentalLockPin(pin: unknown): pin is string {
|
||||
return (
|
||||
typeof pin === 'string' &&
|
||||
pin.length >= PARENTAL_LOCK_PIN_MIN_LENGTH &&
|
||||
pin.length <= PARENTAL_LOCK_PIN_MAX_LENGTH &&
|
||||
/^[0-9]+$/.test(pin)
|
||||
);
|
||||
}
|
||||
|
||||
function getSubtleCrypto(): SubtleCrypto {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (!cryptoApi?.subtle) {
|
||||
throw new Error('WebCrypto is unavailable in this runtime.');
|
||||
}
|
||||
return cryptoApi.subtle;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function derivePinHash(
|
||||
pin: string,
|
||||
salt: Uint8Array,
|
||||
iterations: number
|
||||
): Promise<Uint8Array> {
|
||||
const subtle = getSubtleCrypto();
|
||||
const keyMaterial = await subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(pin),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
);
|
||||
const bits = await subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
hash: 'SHA-256',
|
||||
salt: salt as BufferSource,
|
||||
iterations,
|
||||
},
|
||||
keyMaterial,
|
||||
PIN_HASH_LENGTH_BITS
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export async function hashParentalLockPin(pin: string): Promise<string> {
|
||||
if (!isValidParentalLockPin(pin)) {
|
||||
throw new Error('PIN must be 4 to 8 digits.');
|
||||
}
|
||||
const salt = new Uint8Array(PIN_HASH_SALT_BYTES);
|
||||
globalThis.crypto.getRandomValues(salt);
|
||||
const hash = await derivePinHash(pin, salt, PIN_HASH_ITERATIONS);
|
||||
return [
|
||||
PIN_HASH_VERSION,
|
||||
String(PIN_HASH_ITERATIONS),
|
||||
bytesToBase64(salt),
|
||||
bytesToBase64(hash),
|
||||
].join('$');
|
||||
}
|
||||
|
||||
function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
let difference = 0;
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
difference |= left[index] ^ right[index];
|
||||
}
|
||||
return difference === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a PIN against a stored hash. A malformed stored value never
|
||||
* verifies — it can only be replaced by setting a new PIN.
|
||||
*/
|
||||
export async function verifyParentalLockPin(
|
||||
pin: string,
|
||||
storedHash: string | null | undefined
|
||||
): Promise<boolean> {
|
||||
if (!isValidParentalLockPin(pin) || typeof storedHash !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const parts = storedHash.split('$');
|
||||
if (parts.length !== 4 || parts[0] !== PIN_HASH_VERSION) {
|
||||
return false;
|
||||
}
|
||||
const iterations = Number(parts[1]);
|
||||
if (!Number.isInteger(iterations) || iterations <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const salt = base64ToBytes(parts[2]);
|
||||
const expected = base64ToBytes(parts[3]);
|
||||
const actual = await derivePinHash(pin, salt, iterations);
|
||||
return constantTimeEqual(actual, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
isParentalLockPlaylistLocksEmpty,
|
||||
lockedStalkerCategoryIds,
|
||||
lockedXtreamCategoryIds,
|
||||
normalizeParentalLockPlaylistLocks,
|
||||
normalizeParentalLockStore,
|
||||
toParentalLockStalkerCategoryType,
|
||||
toParentalLockXtreamCategoryType,
|
||||
} from './parental-lock.util';
|
||||
|
||||
describe('parental-lock.util', () => {
|
||||
describe('normalizeParentalLockPlaylistLocks', () => {
|
||||
it('keeps well-formed entries and drops junk, duplicates and the All pseudo-genre', () => {
|
||||
const locks = normalizeParentalLockPlaylistLocks({
|
||||
xtream: [
|
||||
{ categoryType: 'live', xtreamId: 7 },
|
||||
{ categoryType: 'live', xtreamId: '7' },
|
||||
{ categoryType: 'movies', xtreamId: 'abc' },
|
||||
{ categoryType: 'unknown', xtreamId: 1 },
|
||||
null,
|
||||
],
|
||||
stalker: [
|
||||
{ categoryType: 'itv', categoryId: 12 },
|
||||
{ categoryType: 'itv', categoryId: '12' },
|
||||
{ categoryType: 'vod', categoryId: '*' },
|
||||
{ categoryType: 'radio', categoryId: '' },
|
||||
],
|
||||
m3u: ['Adult', 'Adult', 42, 'News'],
|
||||
});
|
||||
|
||||
expect(locks).toEqual({
|
||||
xtream: [{ categoryType: 'live', xtreamId: 7 }],
|
||||
stalker: [{ categoryType: 'itv', categoryId: '12' }],
|
||||
m3u: ['Adult', 'News'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty locks for non-objects', () => {
|
||||
expect(normalizeParentalLockPlaylistLocks(null)).toEqual({
|
||||
xtream: [],
|
||||
stalker: [],
|
||||
m3u: [],
|
||||
});
|
||||
expect(normalizeParentalLockPlaylistLocks('x')).toEqual({
|
||||
xtream: [],
|
||||
stalker: [],
|
||||
m3u: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeParentalLockStore', () => {
|
||||
it('drops playlists that lock nothing', () => {
|
||||
const store = normalizeParentalLockStore({
|
||||
'p-1': { m3u: ['XXX'] },
|
||||
'p-2': { xtream: [], stalker: [], m3u: [] },
|
||||
'': { m3u: ['ignored'] },
|
||||
'p-3': 'junk',
|
||||
});
|
||||
|
||||
expect(Object.keys(store)).toEqual(['p-1']);
|
||||
expect(isParentalLockPlaylistLocksEmpty(store['p-1'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns an empty store for anything that is not an object', () => {
|
||||
expect(normalizeParentalLockStore(undefined)).toEqual({});
|
||||
expect(normalizeParentalLockStore([])).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('id lookups', () => {
|
||||
const locks = normalizeParentalLockPlaylistLocks({
|
||||
xtream: [
|
||||
{ categoryType: 'live', xtreamId: 1 },
|
||||
{ categoryType: 'movies', xtreamId: 2 },
|
||||
],
|
||||
stalker: [
|
||||
{ categoryType: 'itv', categoryId: '10' },
|
||||
{ categoryType: 'series', categoryId: '11' },
|
||||
],
|
||||
});
|
||||
|
||||
it('filters Xtream ids by category type', () => {
|
||||
expect(lockedXtreamCategoryIds(locks, 'live')).toEqual([1]);
|
||||
expect(lockedXtreamCategoryIds(locks, 'series')).toEqual([]);
|
||||
expect(lockedXtreamCategoryIds(undefined, 'live')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters Stalker ids by section', () => {
|
||||
expect(lockedStalkerCategoryIds(locks, 'itv')).toEqual(['10']);
|
||||
expect(lockedStalkerCategoryIds(locks, 'vod')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('section mapping', () => {
|
||||
it('maps Xtream route sections to DB category types', () => {
|
||||
expect(toParentalLockXtreamCategoryType('live')).toBe('live');
|
||||
expect(toParentalLockXtreamCategoryType('vod')).toBe('movies');
|
||||
expect(toParentalLockXtreamCategoryType('movies')).toBe('movies');
|
||||
expect(toParentalLockXtreamCategoryType('series')).toBe('series');
|
||||
expect(toParentalLockXtreamCategoryType('search')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts only Stalker sections with a genre list', () => {
|
||||
expect(toParentalLockStalkerCategoryType('itv')).toBe('itv');
|
||||
expect(toParentalLockStalkerCategoryType('radio')).toBe('radio');
|
||||
expect(toParentalLockStalkerCategoryType('favorites')).toBeNull();
|
||||
expect(toParentalLockStalkerCategoryType(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Parental lock contracts shared by the renderer service, the Electron DB
|
||||
* worker (which stamps Xtream locks onto `categories.locked`) and the
|
||||
* playlist backup format.
|
||||
*
|
||||
* The unit of locking is the CATEGORY: an Xtream category (by provider
|
||||
* `category_id`, stable across refreshes), a Stalker genre (by portal id) or
|
||||
* an M3U `group-title`. The renderer keeps every lock in one store keyed by
|
||||
* playlist id; the SQLite column is only a query index derived from it.
|
||||
*/
|
||||
|
||||
export type ParentalLockPortalType = 'xtream' | 'stalker' | 'm3u';
|
||||
|
||||
/** Xtream category table types (`categories.type`). */
|
||||
export type ParentalLockXtreamCategoryType = 'live' | 'movies' | 'series';
|
||||
|
||||
/** Stalker content sections that carry their own genre list. */
|
||||
export type ParentalLockStalkerCategoryType =
|
||||
'itv' | 'vod' | 'series' | 'radio';
|
||||
|
||||
export interface ParentalLockXtreamCategory {
|
||||
categoryType: ParentalLockXtreamCategoryType;
|
||||
xtreamId: number;
|
||||
}
|
||||
|
||||
export interface ParentalLockStalkerCategory {
|
||||
categoryType: ParentalLockStalkerCategoryType;
|
||||
categoryId: string;
|
||||
}
|
||||
|
||||
export interface ParentalLockPlaylistLocks {
|
||||
xtream: ParentalLockXtreamCategory[];
|
||||
stalker: ParentalLockStalkerCategory[];
|
||||
/** Locked M3U `group-title` values, exact match. */
|
||||
m3u: string[];
|
||||
}
|
||||
|
||||
/** Every locked category, keyed by playlist id. */
|
||||
export type ParentalLockStore = Record<string, ParentalLockPlaylistLocks>;
|
||||
|
||||
/** Storage key for the lock store (Electron `app_state`, PWA localStorage). */
|
||||
export const PARENTAL_LOCK_STORE_KEY = 'parental-lock:locks';
|
||||
/** Storage key for the hashed PIN — deliberately outside `Settings`. */
|
||||
export const PARENTAL_LOCK_PIN_KEY = 'parental-lock:pin';
|
||||
|
||||
export const PARENTAL_LOCK_XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] =
|
||||
['live', 'movies', 'series'];
|
||||
export const PARENTAL_LOCK_STALKER_CATEGORY_TYPES: readonly ParentalLockStalkerCategoryType[] =
|
||||
['itv', 'vod', 'series', 'radio'];
|
||||
|
||||
export function createEmptyParentalLockPlaylistLocks(): ParentalLockPlaylistLocks {
|
||||
return { xtream: [], stalker: [], m3u: [] };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function normalizeNumericId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeParentalLockXtreamCategories(
|
||||
value: unknown
|
||||
): ParentalLockXtreamCategory[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ParentalLockXtreamCategory[] = [];
|
||||
for (const item of toArray(value)) {
|
||||
if (!isRecord(item)) {
|
||||
continue;
|
||||
}
|
||||
const categoryType = item[
|
||||
'categoryType'
|
||||
] as ParentalLockXtreamCategoryType;
|
||||
const xtreamId = normalizeNumericId(item['xtreamId']);
|
||||
if (
|
||||
xtreamId === null ||
|
||||
!PARENTAL_LOCK_XTREAM_CATEGORY_TYPES.includes(categoryType)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = `${categoryType}:${xtreamId}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push({ categoryType, xtreamId });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function normalizeParentalLockStalkerCategories(
|
||||
value: unknown
|
||||
): ParentalLockStalkerCategory[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ParentalLockStalkerCategory[] = [];
|
||||
for (const item of toArray(value)) {
|
||||
if (!isRecord(item)) {
|
||||
continue;
|
||||
}
|
||||
const categoryType = item[
|
||||
'categoryType'
|
||||
] as ParentalLockStalkerCategoryType;
|
||||
const rawId = item['categoryId'];
|
||||
const categoryId =
|
||||
typeof rawId === 'string'
|
||||
? rawId.trim()
|
||||
: typeof rawId === 'number' && Number.isFinite(rawId)
|
||||
? String(rawId)
|
||||
: '';
|
||||
if (
|
||||
!categoryId ||
|
||||
categoryId === '*' ||
|
||||
!PARENTAL_LOCK_STALKER_CATEGORY_TYPES.includes(categoryType)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = `${categoryType}:${categoryId}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push({ categoryType, categoryId });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function normalizeParentalLockGroupTitles(value: unknown): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const item of toArray(value)) {
|
||||
if (typeof item === 'string') {
|
||||
seen.add(item);
|
||||
}
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
export function normalizeParentalLockPlaylistLocks(
|
||||
value: unknown
|
||||
): ParentalLockPlaylistLocks {
|
||||
if (!isRecord(value)) {
|
||||
return createEmptyParentalLockPlaylistLocks();
|
||||
}
|
||||
return {
|
||||
xtream: normalizeParentalLockXtreamCategories(value['xtream']),
|
||||
stalker: normalizeParentalLockStalkerCategories(value['stalker']),
|
||||
m3u: normalizeParentalLockGroupTitles(value['m3u']),
|
||||
};
|
||||
}
|
||||
|
||||
export function isParentalLockPlaylistLocksEmpty(
|
||||
locks: ParentalLockPlaylistLocks
|
||||
): boolean {
|
||||
return (
|
||||
locks.xtream.length === 0 &&
|
||||
locks.stalker.length === 0 &&
|
||||
locks.m3u.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a persisted or user-supplied lock store. Entries that lock
|
||||
* nothing are dropped so the store never grows with empty playlists.
|
||||
*/
|
||||
export function normalizeParentalLockStore(value: unknown): ParentalLockStore {
|
||||
const store: ParentalLockStore = {};
|
||||
if (!isRecord(value)) {
|
||||
return store;
|
||||
}
|
||||
for (const [playlistId, locks] of Object.entries(value)) {
|
||||
if (!playlistId) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeParentalLockPlaylistLocks(locks);
|
||||
if (!isParentalLockPlaylistLocksEmpty(normalized)) {
|
||||
store[playlistId] = normalized;
|
||||
}
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export function lockedXtreamCategoryIds(
|
||||
locks: ParentalLockPlaylistLocks | undefined,
|
||||
categoryType: ParentalLockXtreamCategoryType
|
||||
): number[] {
|
||||
return (locks?.xtream ?? [])
|
||||
.filter((entry) => entry.categoryType === categoryType)
|
||||
.map((entry) => entry.xtreamId);
|
||||
}
|
||||
|
||||
export function lockedStalkerCategoryIds(
|
||||
locks: ParentalLockPlaylistLocks | undefined,
|
||||
categoryType: ParentalLockStalkerCategoryType
|
||||
): string[] {
|
||||
return (locks?.stalker ?? [])
|
||||
.filter((entry) => entry.categoryType === categoryType)
|
||||
.map((entry) => entry.categoryId);
|
||||
}
|
||||
|
||||
/** Maps a Stalker section id to the genre list it locks against. */
|
||||
export function toParentalLockStalkerCategoryType(
|
||||
section: string | null | undefined
|
||||
): ParentalLockStalkerCategoryType | null {
|
||||
switch (section) {
|
||||
case 'itv':
|
||||
case 'vod':
|
||||
case 'series':
|
||||
case 'radio':
|
||||
return section;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps an Xtream route section (`live`/`vod`/`series`) to the DB category type. */
|
||||
export function toParentalLockXtreamCategoryType(
|
||||
section: string | null | undefined
|
||||
): ParentalLockXtreamCategoryType | null {
|
||||
switch (section) {
|
||||
case 'live':
|
||||
return 'live';
|
||||
case 'vod':
|
||||
case 'movies':
|
||||
return 'movies';
|
||||
case 'series':
|
||||
return 'series';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,12 @@ export interface M3uPlaylistBackupEntry extends PlaylistBackupBaseEntry {
|
||||
favorites: string[];
|
||||
recentlyViewed: M3uRecentlyViewedItem[];
|
||||
hiddenGroupTitles: string[];
|
||||
/**
|
||||
* Parental-lock group titles. Optional: absent in archives written
|
||||
* before the lock existed, and absence means "no opinion" — restore
|
||||
* leaves the playlist's existing locks untouched.
|
||||
*/
|
||||
lockedGroupTitles?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +83,12 @@ export interface XtreamBackupUserState {
|
||||
playbackPositions: PlaybackPositionData[];
|
||||
/** Optional: absent in archives written before multi-source existed. */
|
||||
sourcePins?: XtreamBackupSourcePin[];
|
||||
/**
|
||||
* Parental-lock categories, same shape as `hiddenCategories`. Optional
|
||||
* like `sourcePins`: absence means the archive predates the lock and
|
||||
* restore keeps the playlist's existing locks.
|
||||
*/
|
||||
lockedCategories?: XtreamBackupHiddenCategory[];
|
||||
}
|
||||
|
||||
export interface XtreamPlaylistBackupEntry extends PlaylistBackupBaseEntry {
|
||||
@@ -109,9 +121,16 @@ export interface StalkerPlaylistBackupEntry extends PlaylistBackupBaseEntry {
|
||||
userState: {
|
||||
favorites: StalkerPortalItem[];
|
||||
recentlyViewed: StalkerPortalItem[];
|
||||
/** Parental-lock genres; optional with the same absence rule as Xtream. */
|
||||
lockedCategories?: StalkerBackupLockedCategory[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface StalkerBackupLockedCategory {
|
||||
categoryType: 'itv' | 'vod' | 'series' | 'radio';
|
||||
categoryId: string;
|
||||
}
|
||||
|
||||
export type PlaylistBackupEntry =
|
||||
| M3uPlaylistBackupEntry
|
||||
| XtreamPlaylistBackupEntry
|
||||
|
||||
@@ -278,4 +278,36 @@ export interface Settings {
|
||||
* Disabled by default because enrichment sends content titles to TMDB.
|
||||
*/
|
||||
tmdb?: TmdbSettings;
|
||||
/**
|
||||
* Parental lock: locked categories disappear from the whole app until
|
||||
* the PIN is entered. Off by default; may only be on while a PIN exists
|
||||
* (the hash lives outside `Settings`, see `parental-lock-pin.util.ts`).
|
||||
* Mirrored into the main-process config so the SQLite worker filters
|
||||
* before the renderer has announced its lock state.
|
||||
*/
|
||||
parentalLockEnabled?: boolean;
|
||||
/**
|
||||
* Minutes without user interaction after which the app locks itself
|
||||
* again; `0` keeps it unlocked until the next restart. Active playback
|
||||
* counts as interaction. Default 15.
|
||||
*/
|
||||
parentalLockRelockMinutes?: ParentalLockRelockMinutes;
|
||||
}
|
||||
|
||||
export type ParentalLockRelockMinutes = 0 | 5 | 15 | 30 | 60;
|
||||
|
||||
export const PARENTAL_LOCK_RELOCK_MINUTES_OPTIONS: readonly ParentalLockRelockMinutes[] =
|
||||
[0, 5, 15, 30, 60];
|
||||
|
||||
export const DEFAULT_PARENTAL_LOCK_RELOCK_MINUTES: ParentalLockRelockMinutes = 15;
|
||||
|
||||
/** Collapses anything outside the offered choices to the default. */
|
||||
export function normalizeParentalLockRelockMinutes(
|
||||
value: unknown
|
||||
): ParentalLockRelockMinutes {
|
||||
return PARENTAL_LOCK_RELOCK_MINUTES_OPTIONS.includes(
|
||||
value as ParentalLockRelockMinutes
|
||||
)
|
||||
? (value as ParentalLockRelockMinutes)
|
||||
: DEFAULT_PARENTAL_LOCK_RELOCK_MINUTES;
|
||||
}
|
||||
Reference in new issue
Block a user