Merge remote-tracking branch 'origin/master' into claude/admiring-bell-7132d7

This commit is contained in:
4gray committed 2026-09-27 17:58:23 +02:00
commit 673ad89e18
235 files changed
+15137 -477

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
type: feature
area: settings
issues: [285]
highlight: Parental lock
---
New parental lock: set a PIN in Settings → Parental lock, then mark Xtream categories, Stalker genres or M3U groups as locked. Locked categories and their channels disappear from the app until the PIN is entered; the app locks again on restart, after a chosen idle time or with "Lock now".
+9
View File
@@ -15,6 +15,15 @@ on:
schedule:
- cron: '0 20 * * 3'
# A newer push to a pull request cancels that PR's still-running analysis:
# only the latest commit's result matters, and superseded runs otherwise hold
# runners the rest of the pipeline is queued for. Pushes to master, the weekly
# schedule and manual dispatches get a unique group (run_id), so they are never
# cancelled or replaced, the same pattern as ci.yml.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Required so `codeql-action/analyze` can upload its SARIF results. Without an
# explicit grant the default token is read-only and the upload fails with
# "Resource not accessible by integration".
@@ -176,9 +176,15 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
},
{
method: 'dbSaveCategories',
args: [playlistId, categories, 'live', categoryIds],
args: [playlistId, categories, 'live', categoryIds, categoryIds],
channel: 'DB_SAVE_CATEGORIES',
forwardedArgs: [playlistId, categories, 'live', categoryIds],
forwardedArgs: [
playlistId,
categories,
'live',
categoryIds,
categoryIds,
],
},
{
method: 'dbGetAllCategories',
@@ -192,6 +198,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
channel: 'DB_UPDATE_CATEGORY_VISIBILITY',
forwardedArgs: [categoryIds, true],
},
{
method: 'dbSetCategoryLocks',
args: [playlistId, 'live', categoryIds],
channel: 'DB_SET_CATEGORY_LOCKS',
forwardedArgs: [playlistId, 'live', categoryIds],
},
{
method: 'dbHasContent',
args: [playlistId, 'movie'],
@@ -99,6 +99,7 @@ const WINDOW_CONFIRM_CLOSE = 'WINDOW:CONFIRM_CLOSE';
const WINDOW_CANCEL_CLOSE = 'WINDOW:CANCEL_CLOSE';
const WINDOW_CLOSE_REQUESTED = 'WINDOW:CLOSE_REQUESTED';
const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE';
const PARENTAL_LOCK_SET_STATE = 'PARENTAL_LOCK:SET_STATE';
const dbSaveContentProgressListeners = new Set<
(
@@ -461,6 +462,8 @@ const electronApi: ElectronBridgeApi = {
},
setPlaybackKeepAwake: (active: boolean) =>
ipcRenderer.invoke(PLAYBACK_SET_KEEP_AWAKE, active === true),
setParentalLockState: (active: boolean) =>
ipcRenderer.invoke(PARENTAL_LOCK_SET_STATE, active === true),
fetchPlaylistByUrl: (
url: string,
title?: string,
@@ -827,14 +830,16 @@ const electronApi: ElectronBridgeApi = {
playlistId: string,
categories: XtreamCategory[],
type: string,
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[]
) =>
ipcRenderer.invoke(
'DB_SAVE_CATEGORIES',
playlistId,
categories,
type,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds
),
dbGetAllCategories: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type),
@@ -844,6 +849,17 @@ const electronApi: ElectronBridgeApi = {
categoryIds,
hidden
),
dbSetCategoryLocks: (
playlistId: string,
type: string,
lockedXtreamIds: number[]
) =>
ipcRenderer.invoke(
'DB_SET_CATEGORY_LOCKS',
playlistId,
type,
lockedXtreamIds
),
dbHasContent: (playlistId: string, type: string) =>
ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type),
dbGetContent: (playlistId: string, type: string) =>
@@ -91,6 +91,9 @@ export const XTREAM_PRELOAD_TARGET_CASES: TargetCase[] = [
categoryItems,
'live',
[91],
// Parental-lock ids: the preload forwards the optional fifth
// argument as-is, so an omitted one travels as undefined.
undefined,
],
expectedMetadata: {
...EMPTY_METADATA,
@@ -74,6 +74,7 @@ describe('category operation performance phases', () => {
],
'live',
[102],
undefined,
recording.capture
)
).resolves.toEqual({ success: true });
@@ -4,7 +4,15 @@ import {
getAllCategories,
getCategories,
saveCategories,
setCategoryLocks,
} from './category.operations';
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
import type { SQL } from 'drizzle-orm';
import { setParentalLockActive } from '../parental-lock-state';
function renderSql(query: SQL): string {
return new SQLiteSyncDialect().sqlToQuery(query).sql;
}
// Renderer consumers (XCategoryFromDb/XtreamCategoryFromDb) expect category
// rows in this snake_case wire shape. A bare select() would return Drizzle's
@@ -17,6 +25,7 @@ const categoryWireShape = {
type: schema.categories.type,
xtream_id: schema.categories.xtreamId,
hidden: schema.categories.hidden,
locked: schema.categories.locked,
};
function createDbMock(existingCount = 0) {
@@ -93,6 +102,7 @@ describe('category.operations', () => {
type: 'live',
xtreamId: 101,
hidden: false,
locked: false,
},
{
playlistId: 'playlist-1',
@@ -100,6 +110,7 @@ describe('category.operations', () => {
type: 'live',
xtreamId: 102,
hidden: true,
locked: false,
},
]);
});
@@ -125,6 +136,7 @@ describe('category.operations', () => {
type: 'movies',
xtreamId: 201,
hidden: true,
locked: false,
},
]);
});
@@ -143,3 +155,120 @@ describe('category.operations', () => {
expect(insert).not.toHaveBeenCalled();
});
});
describe('category.operations parental lock', () => {
afterEach(() => {
setParentalLockActive(false);
});
function createReadDb() {
const orderBy = jest.fn().mockResolvedValue([]);
const where = jest.fn().mockReturnValue({ orderBy });
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
return { db: { select } as unknown as AppDatabase, where };
}
it('adds the locked filter to visible-category reads only while active', async () => {
const unlocked = createReadDb();
await getCategories(unlocked.db, 'playlist-1', 'live');
expect(renderSql(unlocked.where.mock.calls[0][0])).not.toContain(
'"locked"'
);
setParentalLockActive(true);
const locked = createReadDb();
await getCategories(locked.db, 'playlist-1', 'live');
expect(renderSql(locked.where.mock.calls[0][0])).toContain(
'"categories"."locked" = ?'
);
});
it('never filters the management read, which lists locked rows by design', async () => {
setParentalLockActive(true);
const { db, where } = createReadDb();
await getAllCategories(db, 'playlist-1', 'movies');
expect(renderSql(where.mock.calls[0][0])).not.toContain('"locked"');
});
it('stamps locked from the caller-supplied provider ids on insert', async () => {
const { db, values } = createDbMock(0);
await saveCategories(
db,
'playlist-1',
[
{ category_id: '1', category_name: 'Kids' },
{ category_id: '2', category_name: 'Adult' },
],
'live',
undefined,
[2]
);
expect(values).toHaveBeenCalledWith([
expect.objectContaining({ xtreamId: 1, locked: false }),
expect.objectContaining({ xtreamId: 2, locked: true }),
]);
});
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 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(
where.mock.calls[1][0]
);
expect(lockScope.sql).toContain('"categories"."xtream_id" in (?, ?)');
expect(lockScope.params).toEqual(['playlist-1', 'live', 5, 7]);
});
it('only clears when no id is locked', async () => {
const { db, set } = lockIndexDb();
await setCategoryLocks(db, 'playlist-1', 'series', []);
expect(set).toHaveBeenCalledTimes(1);
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']);
});
});
@@ -2,6 +2,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { unlockedCategoryCondition } from '../parental-lock-state';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
type XtreamCategoryInput = {
@@ -23,6 +24,7 @@ const categoryWireShape = {
type: schema.categories.type,
xtream_id: schema.categories.xtreamId,
hidden: schema.categories.hidden,
locked: schema.categories.locked,
};
function normalizeXtreamCategoryId(
@@ -37,9 +39,11 @@ function normalizeXtreamCategories(
playlistId: string,
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[]
): XtreamCategoryValue[] {
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
const lockedSet = new Set(lockedCategoryXtreamIds || []);
return categories.flatMap((category) => {
const xtreamId = normalizeXtreamCategoryId(category.category_id);
@@ -55,6 +59,7 @@ function normalizeXtreamCategories(
type,
xtreamId,
hidden: hiddenSet.has(xtreamId),
locked: lockedSet.has(xtreamId),
},
];
});
@@ -111,7 +116,8 @@ export async function getCategories(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, type),
eq(schema.categories.hidden, false)
eq(schema.categories.hidden, false),
unlockedCategoryCondition()
)
)
.orderBy(schema.categories.id);
@@ -131,6 +137,7 @@ export async function saveCategories(
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[],
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{ success: boolean }> {
if (!categories || categories.length === 0) {
@@ -159,7 +166,8 @@ export async function saveCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds
),
(result) => ({ itemCount: result.length })
)
@@ -167,7 +175,8 @@ export async function saveCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds
);
if (values.length === 0) {
@@ -220,3 +229,43 @@ export async function updateCategoryVisibility(
return { success: true };
}
/**
* Re-stamps the parental lock index for one playlist and category type from
* the renderer's lock store: listed provider ids become locked, every other
* row of that playlist/type is unlocked. Idempotent by construction, so the
* renderer can replay the store after a refresh or a backup restore.
*/
export async function setCategoryLocks(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movies' | 'series',
lockedXtreamIds: number[]
): Promise<{ success: boolean }> {
const scope = and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, type)
);
const lockedIds = [
...new Set(lockedXtreamIds.filter((id) => Number.isInteger(id))),
];
// 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) {
db.update(schema.categories)
.set({ locked: true })
.where(
and(scope, inArray(schema.categories.xtreamId, lockedIds))
)
.run();
}
});
return { success: true };
}
@@ -15,6 +15,10 @@ import {
XtreamGlobalSearchResult,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
unlockedCategoryCondition,
unlockedCategorySql,
} from '../parental-lock-state';
import {
countContentRowsByCategory,
sumCategoryRowCounts,
@@ -311,6 +315,7 @@ async function selectXtreamGlobalSearchCandidatesWithTitleIndex(
)})
AND ${sql.join(titleConditions, sql` AND `)}
${excludeHidden ? sql`AND cat.hidden = 0` : sql``}
${unlockedCategorySql()}
ORDER BY c.title
LIMIT ${candidateLimit}
`)) as XtreamGlobalSearchCandidate[];
@@ -359,6 +364,7 @@ async function selectXtreamGlobalSearchCandidatesWithFts(
: sql``
}
${excludeHidden ? sql`AND cat.hidden = 0` : sql``}
${unlockedCategorySql()}
ORDER BY rank, c.title
LIMIT ${candidateLimit}
`)) as XtreamGlobalSearchCandidate[];
@@ -382,6 +388,10 @@ async function selectXtreamGlobalSearchCandidatesWithContentScan(
if (excludeHidden) {
conditions.push(eq(schema.categories.hidden, false));
}
const unlockedCondition = unlockedCategoryCondition();
if (unlockedCondition) {
conditions.push(unlockedCondition);
}
return db
.select({
@@ -694,7 +704,8 @@ export async function getContent(
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.content.type, type)
eq(schema.content.type, type),
unlockedCategoryCondition()
)
);
@@ -775,6 +786,7 @@ function getGlobalRecentlyAddedByType(
const whereConditions = [
eq(schema.content.type, type),
eq(schema.categories.hidden, false),
unlockedCategoryCondition(),
sql`${schema.content.added} <> ''`,
sql`${schema.content.added} <= ${getXtreamRecentlyAddedMaxEpochSeconds()}`,
];
@@ -1074,6 +1086,10 @@ export async function searchContent(
if (excludeHidden) {
conditions.push(eq(schema.categories.hidden, false));
}
const unlockedCondition = unlockedCategoryCondition();
if (unlockedCondition) {
conditions.push(unlockedCondition);
}
const query = db
.select(selectContentFields())
@@ -4,6 +4,7 @@ import {
normalizeTitleKeys,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { unlockedCategorySql } from '../parental-lock-state';
/**
* Batched cross-playlist title matching for the actor page's "All portals"
@@ -75,6 +76,7 @@ export async function matchTitles(
WHERE content_title_fts MATCH ${matchQuery}
AND c.type IN ('movie', 'series')
AND cat.hidden = 0
${unlockedCategorySql()}
ORDER BY rank, c.title
LIMIT ${PER_TITLE_CANDIDATE_LIMIT}
`)) as TitleMatchRow[];
@@ -5,6 +5,7 @@ import {
type VodSourceCandidateRow,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { unlockedCategorySql } from '../parental-lock-state';
import {
caseInsensitiveGlobBody,
caseInsensitiveGlobPattern,
@@ -258,6 +259,7 @@ function scanCandidateQuery(
WHERE c.type = 'movie'
AND cat.hidden = 0
AND p.type = 'xtream'
${unlockedCategorySql()}
AND ${wordMatches}
${excludePlaylist}
ORDER BY LENGTH(c.title), c.title
@@ -292,6 +294,7 @@ function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) {
AND c.type = 'movie'
AND cat.hidden = 0
AND p.type = 'xtream'
${unlockedCategorySql()}
${excludePlaylist}
GROUP BY cat.playlist_id, c.xtream_id
ORDER BY rank, c.title
@@ -0,0 +1,47 @@
import { and, eq, type SQL } from 'drizzle-orm';
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
import * as schema from '@iptvnator/shared/database/schema';
function renderSql(query: SQL): string {
return new SQLiteSyncDialect().sqlToQuery(query).sql;
}
import {
isParentalLockActive,
setParentalLockActive,
unlockedCategoryCondition,
unlockedCategorySql,
} from './parental-lock-state';
describe('parental-lock-state', () => {
afterEach(() => {
setParentalLockActive(false);
});
it('starts unlocked and contributes nothing to queries', () => {
expect(isParentalLockActive()).toBe(false);
expect(unlockedCategoryCondition()).toBeUndefined();
expect(renderSql(unlockedCategorySql())).toBe('');
// `and()` must tolerate the undefined condition so callers can add
// it unconditionally.
expect(
and(eq(schema.categories.type, 'live'), undefined)
).toBeDefined();
});
it('filters on categories.locked while active', () => {
setParentalLockActive(true);
expect(isParentalLockActive()).toBe(true);
expect(unlockedCategoryCondition()).toEqual(
eq(schema.categories.locked, false)
);
expect(renderSql(unlockedCategorySql())).toContain(
'AND cat.locked = 0'
);
});
it('coerces anything but true to inactive', () => {
setParentalLockActive('yes' as unknown as boolean);
expect(isParentalLockActive()).toBe(false);
});
});
@@ -0,0 +1,39 @@
import { eq, sql, type SQL } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
/**
* Process-wide parental lock enforcement flag for the SQLite worker.
*
* While active, every content-returning read appends "category is not
* locked", so a surface added later is withheld by default instead of
* leaking. The value arrives from the main process: seeded through
* `workerData` when the worker is (re)started and updated by the
* `DB_SET_PARENTAL_LOCK_STATE` request. It is deliberately not read from
* settings inside the worker, which has no access to the renderer's
* IndexedDB or to electron-conf.
*/
let parentalLockActive = false;
export function setParentalLockActive(active: boolean): void {
parentalLockActive = active === true;
}
export function isParentalLockActive(): boolean {
return parentalLockActive;
}
/**
* Drizzle condition for query-builder reads joined on `categories`;
* `undefined` while unlocked so `and(...)` ignores it.
*/
export function unlockedCategoryCondition(): SQL | undefined {
return parentalLockActive ? eq(schema.categories.locked, false) : undefined;
}
/**
* Raw fragment for `sql` template reads that alias the categories table as
* `cat`; empty while unlocked.
*/
export function unlockedCategorySql(): SQL {
return parentalLockActive ? sql`AND cat.locked = 0` : sql``;
}
@@ -30,12 +30,27 @@ handleWorkerRequest(
category_id: string | number;
}>,
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[]
) => ({
playlistId,
categories,
type,
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds,
})
);
handleWorkerRequest(
'DB_SET_CATEGORY_LOCKS',
(
playlistId: string,
type: 'live' | 'movies' | 'series',
lockedXtreamIds: number[]
) => ({
playlistId,
type,
lockedXtreamIds: Array.isArray(lockedXtreamIds) ? lockedXtreamIds : [],
})
);
@@ -154,12 +154,13 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
},
{
operation: 'DB_SAVE_CATEGORIES',
args: [playlistId, categories, 'live', categoryIds],
args: [playlistId, categories, 'live', categoryIds, categoryIds],
payload: {
playlistId,
categories,
type: 'live',
hiddenCategoryXtreamIds: categoryIds,
lockedCategoryXtreamIds: categoryIds,
},
},
{
@@ -172,6 +173,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
args: [categoryIds, true],
payload: { categoryIds, hidden: true },
},
{
operation: 'DB_SET_CATEGORY_LOCKS',
args: [playlistId, 'live', categoryIds],
payload: { playlistId, type: 'live', lockedXtreamIds: categoryIds },
},
{
operation: 'DB_HAS_CONTENT',
args: [playlistId, 'movie'],
@@ -0,0 +1,96 @@
type IpcHandler = (event: unknown, ...args: unknown[]) => Promise<unknown>;
const mockRegisteredHandlers = new Map<string, IpcHandler>();
const mockSetParentalLockState = jest.fn();
const mockStoreGet = jest.fn();
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn((channel: string, handler: IpcHandler) => {
mockRegisteredHandlers.set(channel, handler);
}),
},
}));
jest.mock('../services/database-worker-client', () => ({
databaseWorkerClient: {
setParentalLockState: (...args: unknown[]) =>
mockSetParentalLockState(...args),
},
}));
jest.mock('../services/store.service', () => ({
PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED',
store: { get: (...args: unknown[]) => mockStoreGet(...args) },
}));
type Listener = (...args: unknown[]) => void;
function createSender() {
const listeners = new Map<string, Listener>();
return {
id: 1,
on: jest.fn((event: string, listener: Listener) => {
listeners.set(event, listener);
}),
off: jest.fn(),
emit(event: string, ...args: unknown[]) {
listeners.get(event)?.(...args);
},
};
}
describe('parental-lock.events', () => {
beforeEach(async () => {
jest.resetModules();
mockRegisteredHandlers.clear();
mockSetParentalLockState.mockReset().mockResolvedValue(undefined);
mockStoreGet.mockReset().mockReturnValue(true);
const module = await import('./parental-lock.events');
module.default.bootstrapParentalLockEvents();
});
it('forwards the renderer state to the worker and remembers it', async () => {
const { getParentalLockActive } =
await import('../services/parental-lock-state');
const handler = mockRegisteredHandlers.get('PARENTAL_LOCK:SET_STATE');
expect(handler).toBeDefined();
await handler?.({ sender: createSender() }, false);
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false);
expect(getParentalLockActive()).toBe(false);
await handler?.({ sender: createSender() }, 'yes');
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false);
});
it('locks again from the mirrored setting when the renderer reloads or dies', async () => {
const handler = mockRegisteredHandlers.get('PARENTAL_LOCK:SET_STATE');
const sender = createSender();
await handler?.({ sender }, false);
mockSetParentalLockState.mockClear();
sender.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: true,
});
expect(mockSetParentalLockState).not.toHaveBeenCalled();
sender.emit('did-start-navigation', {
isMainFrame: true,
isSameDocument: false,
});
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(true);
mockStoreGet.mockReturnValue(false);
sender.emit('render-process-gone');
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false);
});
it('seeds the main-process copy from the mirrored setting at bootstrap', async () => {
const { getParentalLockActive } =
await import('../services/parental-lock-state');
expect(getParentalLockActive()).toBe(true);
});
});
@@ -0,0 +1,85 @@
import { ipcMain, WebContents } from 'electron';
import { PARENTAL_LOCK_SET_STATE } from '@iptvnator/shared/interfaces';
import { databaseWorkerClient } from '../services/database-worker-client';
import {
getParentalLockActive,
setParentalLockActiveState,
} from '../services/parental-lock-state';
import { PARENTAL_LOCK_ENABLED, store } from '../services/store.service';
/**
* Parental lock IPC.
*
* The renderer reports whether locked categories must be withheld (feature
* enabled and no PIN entered). The value is kept in the main process and
* pushed into the SQLite worker, which filters every content read on it.
*
* Like the playback keep-awake vote, the renderer's word must not outlive the
* page that gave it: on reload, navigation or a dead render process the flag
* falls back to the mirrored `parentalLockEnabled` setting, i.e. locked
* while the feature is on. A crashed page can therefore never leave the
* library unlocked.
*/
const senderCleanups = new Map<number, () => void>();
export async function applyParentalLockState(active: boolean): Promise<void> {
setParentalLockActiveState(active);
try {
await databaseWorkerClient.setParentalLockState(
getParentalLockActive()
);
} catch (error) {
console.error('Failed to update parental lock state:', error);
throw error;
}
}
/** The state a renderer that has not announced itself yet must get. */
export function defaultParentalLockState(): boolean {
return store.get(PARENTAL_LOCK_ENABLED, false) === true;
}
function watchSenderLifetime(sender: WebContents): void {
const senderId = sender.id;
if (senderCleanups.has(senderId)) {
return;
}
const relock = () => {
void applyParentalLockState(defaultParentalLockState()).catch(
() => undefined
);
};
const onNavigation = (
event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>
) => {
if (event.isMainFrame && !event.isSameDocument) {
relock();
}
};
const onDestroyed = () => {
relock();
senderCleanups.get(senderId)?.();
senderCleanups.delete(senderId);
};
sender.on('destroyed', onDestroyed);
sender.on('render-process-gone', relock);
sender.on('did-start-navigation', onNavigation);
senderCleanups.set(senderId, () => {
sender.off('destroyed', onDestroyed);
sender.off('render-process-gone', relock);
sender.off('did-start-navigation', onNavigation);
});
}
export default class ParentalLockEvents {
static bootstrapParentalLockEvents(): Electron.IpcMain {
setParentalLockActiveState(defaultParentalLockState());
return ipcMain;
}
}
ipcMain.handle(PARENTAL_LOCK_SET_STATE, async (event, active: boolean) => {
watchSenderLifetime(event.sender);
await applyParentalLockState(active === true);
});
@@ -38,6 +38,7 @@ const handlers = new Map<string, SettingsUpdateHandler>();
const mockStoreGet = jest.fn();
const mockStoreSet = jest.fn();
const mockUpdateSettings = jest.fn();
const mockApplyParentalLockState = jest.fn();
const mockIpcHandle = jest.fn(
(channel: string, handler: SettingsUpdateHandler): void => {
handlers.set(channel, handler);
@@ -61,12 +62,18 @@ jest.mock('../services/store.service', () => ({
PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS,
VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE,
PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED',
store: {
get: mockStoreGet,
set: mockStoreSet,
},
}));
jest.mock('./parental-lock.events', () => ({
applyParentalLockState: (...args: unknown[]) =>
mockApplyParentalLockState(...args),
}));
jest.mock('../server/http-server', () => ({
httpServer: {
updateSettings: mockUpdateSettings,
@@ -84,6 +91,7 @@ describe('SETTINGS_UPDATE', () => {
mockStoreGet.mockReset();
mockStoreSet.mockReset();
mockUpdateSettings.mockReset();
mockApplyParentalLockState.mockReset().mockResolvedValue(undefined);
mockStoreGet.mockImplementation(
(_key: string, fallbackValue: unknown): unknown => fallbackValue
);
@@ -136,6 +144,35 @@ describe('SETTINGS_UPDATE', () => {
);
});
it('mirrors the parental lock switch and releases the worker only on switch-off', () => {
// Off → on: persist; the renderer announces its own live state.
settingsUpdateHandler({}, { parentalLockEnabled: true });
expect(mockStoreSet).toHaveBeenCalledWith(
'PARENTAL_LOCK_ENABLED',
true
);
expect(mockApplyParentalLockState).not.toHaveBeenCalled();
// An ordinary save carrying the unchanged flag must not re-lock a
// worker the renderer believes is unlocked.
mockStoreGet.mockImplementation((key: string, fallback: unknown) =>
key === 'PARENTAL_LOCK_ENABLED' ? true : fallback
);
settingsUpdateHandler(
{},
{ parentalLockEnabled: true, showCaptions: true }
);
expect(mockApplyParentalLockState).not.toHaveBeenCalled();
// On → off: release at once.
settingsUpdateHandler({}, { parentalLockEnabled: false });
expect(mockStoreSet).toHaveBeenCalledWith(
'PARENTAL_LOCK_ENABLED',
false
);
expect(mockApplyParentalLockState).toHaveBeenCalledWith(false);
});
it('normalizes external-player arguments and preserves explicit false reuse settings', () => {
settingsUpdateHandler(
{},
@@ -11,6 +11,7 @@ import {
EMBEDDED_MPV_FRAME_COPY,
MPV_PLAYER_ARGUMENTS,
MPV_REUSE_INSTANCE,
PARENTAL_LOCK_ENABLED,
STARTUP_WINDOW_MODE,
PORTAL_CONNECTIVITY_GUARD,
store,
@@ -19,6 +20,7 @@ import {
} from '../services/store.service';
import { httpServer } from '../server/http-server';
import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard';
import { applyParentalLockState } from './parental-lock.events';
import { persistAppUpdateChannel } from '../services/app-update-channel';
export default class SettingsEvents {
@@ -42,6 +44,22 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
setHostConnectivityGuardEnabled(enabled);
}
// Mirrored so the database worker and a reloaded renderer start locked
// whenever the feature is on. The LIVE enforcement state is the
// renderer's to announce through PARENTAL_LOCK_SET_STATE: every full
// settings save carries this flag unchanged, so applying it here would
// silently re-lock the worker under a renderer that still shows
// "unlocked". Only a switch-off releases the worker at once — nothing
// may stay withheld once the feature is gone.
if (arg.parentalLockEnabled !== undefined) {
const enabled = arg.parentalLockEnabled === true;
const wasEnabled = store.get(PARENTAL_LOCK_ENABLED, false) === true;
store.set(PARENTAL_LOCK_ENABLED, enabled);
if (wasEnabled && !enabled) {
void applyParentalLockState(false).catch(() => undefined);
}
}
if (arg.mpvPlayerArguments !== undefined) {
store.set(
MPV_PLAYER_ARGUMENTS,
@@ -86,6 +86,7 @@ describe('DatabaseWorkerClient', () => {
nativeModuleSearchPaths: [
'/mock/resources/app.asar.unpacked/node_modules',
],
parentalLockActive: false,
},
});
@@ -2,6 +2,7 @@ import { app } from 'electron';
import { randomUUID } from 'crypto';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { getParentalLockActive } from './parental-lock-state';
import { Worker } from 'worker_threads';
import type {
DbOperationEvent,
@@ -83,6 +84,19 @@ export class DatabaseWorkerClient {
});
}
/**
* Flips the worker's parental lock filter. Awaits readiness so the
* message is never lost to a worker that has not started, and is posted
* on the request port so later reads observe it in order.
*/
async setParentalLockState(active: boolean): Promise<void> {
await this.ensureWorker();
this.worker?.postMessage({
type: 'parental-lock',
active: active === true,
});
}
async cancel(operationId: string): Promise<{ success: boolean }> {
if (!operationId) {
return { success: false };
@@ -157,6 +171,10 @@ export class DatabaseWorkerClient {
this.worker = new Worker(workerURL, {
workerData: {
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
// Seeded here rather than requested afterwards, so a
// restarted worker never answers a read unfiltered while
// the lock state is still in flight.
parentalLockActive: getParentalLockActive(),
},
});
} catch (error) {
@@ -0,0 +1,18 @@
/**
* Main-process copy of the parental lock enforcement flag.
*
* The renderer owns the unlock decision and reports it over
* `PARENTAL_LOCK_SET_STATE`; this module remembers the latest value so the
* database worker can be seeded with it whenever it is (re)started, and so a
* renderer reload or crash can fall back to "locked while the feature is on"
* without waiting for a page that may never come back.
*/
let parentalLockActive = false;
export function getParentalLockActive(): boolean {
return parentalLockActive;
}
export function setParentalLockActiveState(active: boolean): void {
parentalLockActive = active === true;
}
@@ -33,6 +33,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
/** Desktop portal request cooldown; absent means enabled. */
export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD';
/**
* Parental lock feature switch, mirrored from the renderer's settings by the
* SETTINGS_UPDATE handler. Read when the database worker starts and when a
* renderer reloads or dies, so the SQLite reads are locked before any page
* has announced its lock state. Absent means off.
*/
export const PARENTAL_LOCK_ENABLED = 'PARENTAL_LOCK_ENABLED';
/**
* Update channel (`stable` / `nightly`). Mirrored here from the renderer's
* settings by the SETTINGS_UPDATE handler because the startup update check
@@ -78,6 +86,7 @@ export type StoreType = {
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
[STARTUP_WINDOW_MODE]: StartupWindowMode;
[PORTAL_CONNECTIVITY_GUARD]: boolean;
[PARENTAL_LOCK_ENABLED]: boolean;
[APP_UPDATE_CHANNEL]: AppUpdateChannel;
[TRUSTED_LOCAL_EPG_SOURCES]: string[];
};
@@ -35,6 +35,7 @@ import { shutdownMpvSession } from '../events/mpv-session.service';
import PlayerEvents from '../events/player.events';
import { shutdownVlcSession } from '../events/vlc-session.service';
import PlaylistEvents from '../events/playlist.events';
import ParentalLockEvents from '../events/parental-lock.events';
import RemoteControlEvents from '../events/remote-control.events';
import SettingsEvents from '../events/settings.events';
import SharedEvents from '../events/shared.events';
@@ -85,6 +86,7 @@ export function bootstrapDeferredEvents(
SharedEvents.bootstrapSharedEvents();
PlayerEvents.bootstrapPlayerEvents();
SettingsEvents.bootstrapSettingsEvents();
ParentalLockEvents.bootstrapParentalLockEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
@@ -6,6 +6,7 @@ export const DB_WORKER_OPERATIONS = [
'DB_SAVE_CATEGORIES',
'DB_GET_ALL_CATEGORIES',
'DB_UPDATE_CATEGORY_VISIBILITY',
'DB_SET_CATEGORY_LOCKS',
'DB_HAS_CONTENT',
'DB_GET_CONTENT',
'DB_GET_GLOBAL_RECENTLY_ADDED',
@@ -131,6 +132,16 @@ export interface DbWorkerCancelMessage {
operationId: string;
}
/**
* Main-process control message, not a renderer request: flips the worker's
* parental lock filter. Ordered with the requests on the same port, so a
* read posted after it observes the new state.
*/
export interface DbWorkerParentalLockMessage {
type: 'parental-lock';
active: boolean;
}
export interface DbWorkerReadyMessage {
type: 'ready';
}
@@ -158,7 +169,9 @@ export interface DbWorkerResponseMessage<TResult = unknown> {
}
export type DbWorkerIncomingMessage =
DbWorkerRequestMessage | DbWorkerCancelMessage;
| DbWorkerRequestMessage
| DbWorkerCancelMessage
| DbWorkerParentalLockMessage;
export type DbWorkerMessage =
| DbWorkerReadyMessage
@@ -3,7 +3,7 @@ import {
closeWorkerDatabase,
getWorkerDatabase,
} from './database.worker-connection';
import { parentPort } from 'worker_threads';
import { parentPort, workerData } from 'worker_threads';
import type {
ContentMetadataPatch,
VodSourcePin,
@@ -25,8 +25,10 @@ import {
getCategories,
hasCategories,
saveCategories,
setCategoryLocks,
updateCategoryVisibility,
} from '../database/operations/category.operations';
import { setParentalLockActive } from '../database/parental-lock-state';
import {
addFavorite,
getAllGlobalFavorites,
@@ -133,6 +135,12 @@ import {
} from './operation-progress-throttle';
const loggerLabel = '[DB Worker]';
// Seeded by the main process so a (re)started worker is locked before its
// first read; DB_SET_PARENTAL_LOCK_STATE updates it afterwards.
setParentalLockActive(
(workerData as { parentalLockActive?: unknown } | undefined)
?.parentalLockActive === true
);
const batchDelayMs = Number.parseInt(
process.env['IPTVNATOR_DB_WORKER_BATCH_DELAY_MS'] ?? '0',
10
@@ -440,6 +448,7 @@ async function executeRequest(
}>;
type: 'live' | 'movies' | 'series';
hiddenCategoryXtreamIds?: number[];
lockedCategoryXtreamIds?: number[];
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
@@ -449,6 +458,7 @@ async function executeRequest(
payload.categories,
payload.type,
payload.hiddenCategoryXtreamIds,
payload.lockedCategoryXtreamIds,
capturePhase
);
}
@@ -473,6 +483,20 @@ async function executeRequest(
);
}
case 'DB_SET_CATEGORY_LOCKS': {
const payload = message.payload as {
playlistId: string;
type: 'live' | 'movies' | 'series';
lockedXtreamIds: number[];
};
return setCategoryLocks(
db,
payload.playlistId,
payload.type,
payload.lockedXtreamIds
);
}
case 'DB_HAS_CONTENT': {
const payload = message.payload as {
playlistId: string;
@@ -1246,6 +1270,11 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
return;
}
if (message.type === 'parental-lock') {
setParentalLockActive(message.active === true);
return;
}
if (message.type === 'cancel') {
const activeOperation = activeOperations.get(message.operationId);
if (activeOperation) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+70 -14
View File
@@ -12,7 +12,9 @@ async function openSettings(page: Page) {
/** Settings render one section page at a time — open it via the rail. */
async function openSettingsSection(page: Page, sectionId: string) {
await page.locator(`[data-test-id="settings-section-${sectionId}"]`).click();
await page
.locator(`[data-test-id="settings-section-${sectionId}"]`)
.click();
await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`));
}
@@ -41,11 +43,11 @@ test.describe('Settings', () => {
await openSettings(page);
await openSettingsSection(page, 'playback');
const playerSelect = page.locator('[data-test-id="select-video-player"]');
await expect(playerSelect).toContainText(
/Video\.js/i
const playerSelect = page.locator(
'[data-test-id="select-video-player"]'
);
await expect(playerSelect).toContainText(/Video\.js/i);
await playerSelect.click();
await page.locator('mat-option[data-test-id="html5"]').click();
@@ -54,9 +56,7 @@ test.describe('Settings', () => {
await openSettings(page);
await openSettingsSection(page, 'playback');
await expect(playerSelect).toContainText(
/HTML5/i
);
await expect(playerSelect).toContainText(/HTML5/i);
});
test('@settings @web Opt out of shared web player controls', async ({
@@ -235,13 +235,71 @@ test.describe('Settings', () => {
).toHaveAttribute('aria-checked', 'true');
});
test('@settings @web Parental lock — set a PIN, lock, survive a reload, unlock', async ({
page,
}) => {
await openSettings(page);
await openSettingsSection(page, 'parental');
const enableToggle = page.locator(
'[data-test-id="parental-lock-enabled"] button[role="switch"]'
);
const pinInput = page.locator('[data-test-id="parental-lock-pin"]');
const pinConfirm = page.locator(
'[data-test-id="parental-lock-pin-confirm"]'
);
const pinSubmit = page.locator(
'[data-test-id="parental-lock-pin-submit"]'
);
const lockNow = page.locator('[data-test-id="parental-lock-lock-now"]');
const unlock = page.locator('[data-test-id="parental-lock-unlock"]');
const headerLock = page.locator(
'[data-test-id="header-parental-lock"]'
);
// Enabling asks for a new PIN twice; the parent stays unlocked.
await expect(enableToggle).toHaveAttribute('aria-checked', 'false');
await enableToggle.click();
await expect(pinInput).toBeVisible();
await pinInput.fill('2468');
await pinConfirm.fill('2468');
await pinSubmit.click();
await expect(enableToggle).toHaveAttribute('aria-checked', 'true');
await expect(lockNow).toBeVisible();
await expect(headerLock).toBeVisible();
// Lock now flips the state; a reload keeps the lock (never persisted
// as unlocked).
await lockNow.click();
await expect(unlock).toBeVisible();
await page.reload();
await openSettings(page);
await openSettingsSection(page, 'parental');
await expect(enableToggle).toHaveAttribute('aria-checked', 'true');
await expect(unlock).toBeVisible();
// A wrong PIN is refused, the right one unlocks.
await unlock.click();
await expect(pinInput).toBeVisible();
await pinInput.fill('0000');
await pinSubmit.click();
await expect(
page.locator('[data-test-id="parental-lock-pin-error"]')
).toBeVisible();
await pinInput.fill('2468');
await pinSubmit.click();
await expect(lockNow).toBeVisible();
// The header button locks from anywhere.
await headerLock.click();
await expect(unlock).toBeVisible();
});
test('@settings @web Change app language', async ({ page }) => {
await openSettings(page);
const languageSelect = page.locator('[data-test-id="select-language"]');
await expect(languageSelect).toContainText(
'English'
);
await expect(languageSelect).toContainText('English');
await languageSelect.click();
await page.locator('mat-option[data-test-id="de"]').click();
@@ -249,9 +307,7 @@ test.describe('Settings', () => {
await page.reload();
await openSettings(page);
await expect(languageSelect).toContainText(
'Deutsch'
);
await expect(languageSelect).toContainText('Deutsch');
});
test('@settings @search @web Search settings from the header and open a result', async ({
+1 -1
View File
@@ -2,7 +2,7 @@ import { registerLocaleData } from '@angular/common';
import localeEn from '@angular/common/locales/en';
import { inject, Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { normalizeDateLocale } from '@iptvnator/pipes/date-format';
import { createDevLogger } from '@iptvnator/shared/interfaces';
type LocaleDataModule = { default: unknown };
+10
View File
@@ -17,9 +17,11 @@ import { EMPTY, of } from 'rxjs';
import {
DataService,
EpgSourceSettingsService,
ParentalLockService,
SettingsStore,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service';
import {
Language,
Settings,
@@ -97,6 +99,14 @@ describe('AppComponent', () => {
imports: [AppComponent],
providers: [
provideMockStore(),
// The parental lock boots from AppComponent; its collaborators
// (Xtream/Stalker stores, SQLite bridge) are out of scope here.
MockProvider(ParentalLockService, {
initialize: jest.fn().mockResolvedValue(undefined),
}),
MockProvider(ParentalLockEnforcementService, {
start: jest.fn(),
}),
{
provide: Actions,
useValue: new Actions(EMPTY),
+11
View File
@@ -28,6 +28,7 @@ import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { filter, take } from 'rxjs';
import {
DataService,
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
EpgSourceSettingsService,
@@ -42,6 +43,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { AppDateLocaleService } from './app-date-locales';
import { SettingsService } from './services/settings.service';
import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service';
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
@@ -83,6 +85,10 @@ export class AppComponent implements OnInit {
private settingsStore = inject(SettingsStore);
private readonly epgSources = inject(EpgSourceSettingsService);
private playbackKeepAwake = inject(PlaybackKeepAwakeService);
private readonly parentalLock = inject(ParentalLockService);
private readonly parentalLockEnforcement = inject(
ParentalLockEnforcementService
);
private playlistOpenRequests = inject(PlaylistOpenRequestService);
private runtime = inject(RuntimeCapabilitiesService);
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
@@ -108,6 +114,11 @@ export class AppComponent implements OnInit {
// (Electron powerSaveBlocker / PWA Screen Wake Lock, issue #1095).
this.playbackKeepAwake.start();
// Parental lock: load the PIN hash and lock store, then keep the
// in-memory catalogs in step with lock/unlock (issue #285).
void this.parentalLock.initialize();
this.parentalLockEnforcement.start();
effect(() => {
const size = this.settingsStore.coverSize?.() ?? 'medium';
document.documentElement.dataset.coverSize = size;
+8 -2
View File
@@ -30,16 +30,20 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
} from '@iptvnator/portal/shared/util/tokens';
import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui/stalker-connection-editor';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services';
import {
provideParentalLockPlaylistCleanup,
DataService,
} from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
import { AppConfig } from '../environments/environment';
import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
import { ExternalPlaybackService } from './services/external-playback.service';
import { PlayerService } from './services/player.service';
import { provideParentalLockPrompt } from './services/parental-lock-prompt.service';
import { providePortalPlaybackPositions } from './services/portal-playback-positions.service';
import { PwaService } from './services/pwa.service';
import { shouldEnableServiceWorker } from './services/runtime-config';
@@ -160,6 +164,8 @@ export const appConfig: ApplicationConfig = {
useExisting: LazyStalkerPlaylistConnectionEditor,
},
...provideWorkspaceShellActions(),
...provideParentalLockPrompt(),
provideParentalLockPlaylistCleanup(),
...provideXtreamDataSource(),
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
+54 -3
View File
@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { SettingsStore } from '@iptvnator/services';
import { ParentalLockService, SettingsStore } from '@iptvnator/services';
describe('app routes', () => {
let workspaceRoute: import('@angular/router').Route | undefined;
@@ -67,6 +67,52 @@ describe('app routes', () => {
TestBed.resetTestingModule();
});
it('waits for the parental lock state before activating workspace children', async () => {
let releaseLock!: () => void;
const lockPending = new Promise<void>((resolve) => {
releaseLock = resolve;
});
const initialize = jest.fn(() => lockPending);
TestBed.configureTestingModule({
providers: [
{
provide: SettingsStore,
useValue: {
loadSettings: jest.fn().mockResolvedValue(undefined),
},
},
{ provide: ParentalLockService, useValue: { initialize } },
],
});
const settingsReadyResolver =
workspaceRoute?.resolve?.['settingsReady'];
if (typeof settingsReadyResolver !== 'function') {
throw new Error('resolver missing');
}
let resolved = false;
const resolution = TestBed.runInInjectionContext(() =>
Promise.resolve(
(
settingsReadyResolver as import('@angular/router').ResolveFn<void>
)(
{} as import('@angular/router').ActivatedRouteSnapshot,
{} as import('@angular/router').RouterStateSnapshot
)
)
).then(() => {
resolved = true;
});
await Promise.resolve();
await Promise.resolve();
expect(initialize).toHaveBeenCalled();
expect(resolved).toBe(false);
releaseLock();
await resolution;
expect(resolved).toBe(true);
});
it('waits for settings before activating workspace children', async () => {
let releaseSettings!: () => void;
const settingsPending = new Promise<void>((resolve) => {
@@ -79,6 +125,12 @@ describe('app routes', () => {
provide: SettingsStore,
useValue: { loadSettings },
},
{
provide: ParentalLockService,
useValue: {
initialize: jest.fn().mockResolvedValue(undefined),
},
},
],
});
const settingsReadyResolver =
@@ -120,8 +172,7 @@ describe('app routes', () => {
(route) => route.path === 'playlists/:id'
);
const loadChildren = playlistRoute?.loadChildren as
| (() => Promise<typeof workspaceChildren>)
| undefined;
(() => Promise<typeof workspaceChildren>) | undefined;
const m3uRoutes = (await loadChildren?.()) ?? [];
const defaultRoute = m3uRoutes.find((route) => route.path === '');
const favoritesRoute = m3uRoutes.find(
+16 -2
View File
@@ -1,10 +1,24 @@
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard';
const settingsReadyResolver = () => inject(SettingsStore).loadSettings();
// The workspace activates only once settings AND the parental lock state
// (PIN, lock store) are known: before that the lock reads as off and a
// slower IndexedDB read would let the catalogs admit protected rows.
const settingsReadyResolver = async () => {
const settingsStore = inject(SettingsStore);
const parentalLock = inject(ParentalLockService);
await Promise.all([
settingsStore.loadSettings(),
parentalLock.initialize(),
]);
};
const workspaceEntryRedirect = async () =>
inject(WorkspaceStartupPreferencesService).resolveInitialWorkspacePath();
@@ -36,7 +36,7 @@ import {
createLogger,
createPortalDebugRequestContext,
logPortalDebugEvent,
} from '@iptvnator/portal/shared/util';
} from '@iptvnator/portal/shared/util/logger';
interface PlayerLaunchPayload {
readonly headers?: Record<string, string>;
@@ -0,0 +1,583 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { ParentalLockEnforcementService } from './parental-lock-enforcement.service';
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
interface Applier {
apply(): Promise<void>;
failClosedNow(): void;
applyXtream(version: number): Promise<void>;
applyStalker(): Promise<void>;
}
describe('ParentalLockEnforcementService', () => {
const router = { url: '/', navigate: jest.fn() };
const activeChannel = signal<{ group?: { title: string } } | null>(null);
const dispatch = jest.fn();
const lockedStalkerIds = new Set<string>();
const parentalLock = {
version: signal(0),
active: signal(false),
registerBusyProbe: jest.fn(),
isXtreamCategoryLocked: jest.fn(() => false),
isStalkerCategoryLocked: jest.fn(
(_playlistId: string, _type: string, id: unknown) =>
id !== null &&
id !== undefined &&
lockedStalkerIds.has(String(id))
),
isM3uGroupLocked: jest.fn(() => false),
};
const stalkerStore = {
currentPlaylist: signal<{ _id: string } | null>({ _id: 'stalker-1' }),
selectedContentType: signal<string>('vod'),
selectedCategoryId: signal<string | null>('*'),
selectedItem: signal<{
category_id?: string;
tv_genre_id?: string;
} | null>(null),
clearSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
const xtreamStore = {
playlistId: signal<string | null>('xtream-1'),
selectedCategoryId: signal<number | null>(null),
selectedItem: signal<{ category_id?: number } | null>(null),
reloadCategories: jest.fn(async () => undefined),
reloadCachedContent: jest.fn(async () => undefined),
refreshSearchResults: jest.fn(async () => undefined),
withholdCatalog: jest.fn(),
clearSearchResults: jest.fn(),
getCategoriesBySelectedType: jest.fn(() => [
{ id: 7, xtream_id: 70 },
{ id: 8, xtream_id: 80 },
]),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
const xtreamDataSource = {
getAllCategories: jest.fn(async () => [
{ id: 7, xtream_id: 70 },
{ id: 8, xtream_id: 80 },
{ id: 55, xtream_id: 550 },
{ id: 99, xtream_id: 990 },
]),
};
let service: Applier;
beforeEach(() => {
jest.clearAllMocks();
lockedStalkerIds.clear();
router.url = '/';
parentalLock.active.set(false);
parentalLock.isXtreamCategoryLocked.mockReturnValue(false);
stalkerStore.selectedCategoryId.set('*');
stalkerStore.selectedItem.set(null);
xtreamStore.selectedCategoryId.set(null);
xtreamStore.selectedItem.set(null);
TestBed.configureTestingModule({
providers: [
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: XtreamStore, useValue: xtreamStore },
{ provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource },
{ provide: StalkerStore, useValue: stalkerStore },
{ provide: Router, useValue: router },
{
provide: Store,
useValue: { selectSignal: () => activeChannel, dispatch },
},
{
provide: PlaybackKeepAwakeService,
useValue: { hasPlayingVideo: () => false },
},
],
});
activeChannel.set(null);
service = TestBed.inject(
ParentalLockEnforcementService
) as unknown as Applier;
});
describe('M3U', () => {
it('resets a locked playing channel before awaiting the portal reloads', async () => {
router.url = '/workspace/playlists/m3u-1';
activeChannel.set({ group: { title: 'Adult' } });
parentalLock.isM3uGroupLocked.mockReturnValue(true);
let releaseReload: () => void = () => undefined;
xtreamStore.reloadCategories.mockImplementationOnce(
() => new Promise<void>((resolve) => (releaseReload = resolve))
);
const applying = service.apply();
await Promise.resolve();
expect(parentalLock.isM3uGroupLocked).toHaveBeenCalledWith(
'm3u-1',
'Adult'
);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
releaseReload();
await applying;
parentalLock.isM3uGroupLocked.mockReturnValue(false);
});
});
it('resets a channel of a locked group as soon as it becomes active while locked', () => {
// Numeric zapping, next/previous or a remote command can select a
// channel without any lock-version change following it.
router.url = '/workspace/playlists/m3u-1';
parentalLock.active.set(true);
parentalLock.isM3uGroupLocked.mockImplementation(
(_playlistId: string, group: string) => group === 'Adult'
);
TestBed.runInInjectionContext(() =>
(service as unknown as { start(): void }).start()
);
TestBed.flushEffects();
dispatch.mockClear();
activeChannel.set({ group: { title: 'News' } });
TestBed.flushEffects();
expect(dispatch).not.toHaveBeenCalled();
activeChannel.set({ group: { title: 'Adult' } });
TestBed.flushEffects();
expect(dispatch).toHaveBeenCalledTimes(1);
parentalLock.isM3uGroupLocked.mockReset();
parentalLock.isM3uGroupLocked.mockReturnValue(false);
});
describe('Stalker', () => {
it('leaves the Stalker route when the Stalker step cannot load', async () => {
router.url = '/workspace/stalker/stalker-1/itv';
jest.spyOn(console, 'error').mockImplementation(() => undefined);
(
service as unknown as {
loadStalkerEnforcement: () => Promise<unknown>;
}
).loadStalkerEnforcement = () =>
Promise.reject(new Error('ChunkLoadError'));
await expect(service.applyStalker()).resolves.toBeUndefined();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'sources',
]);
});
it('leaves the Stalker route synchronously on relock while the step is not loaded', () => {
router.url = '/workspace/stalker/stalker-1/itv';
(
service as unknown as {
loadStalkerEnforcement: () => Promise<unknown>;
}
).loadStalkerEnforcement = () => new Promise(() => undefined);
service.failClosedNow();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'sources',
]);
});
it('runs a preloaded Stalker step synchronously on relock', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
await service.applyStalker(); // loads the step
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '9' });
stalkerStore.clearSelectedItem.mockClear();
service.failClosedNow();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
});
it('does not load the Stalker step outside a Stalker route', async () => {
router.url = '/workspace/xtreams/xtream-1/live';
const load = jest.spyOn(
service as unknown as { loadStalkerEnforcement: () => unknown },
'loadStalkerEnforcement'
);
await service.applyStalker();
expect(load).not.toHaveBeenCalled();
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
});
it('clears a detail opened from All whose own genre is withheld', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '9' });
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
// "All" itself is not locked, so the category stays selected.
expect(stalkerStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'stalker',
'stalker-1',
'vod',
]);
});
it('leaves a detail from All alone when its genre is not locked', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '3' });
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('judges a live channel from All by its genre, not by category_id', async () => {
router.url = '/workspace/stalker/stalker-1/itv';
stalkerStore.selectedContentType.set('itv');
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({
tv_genre_id: '9',
category_id: '3',
});
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'stalker',
'stalker-1',
'itv',
]);
stalkerStore.selectedContentType.set('vod');
});
it('steps off a locked selected category', async () => {
router.url = '/workspace/stalker/stalker-1/vod';
lockedStalkerIds.add('9');
stalkerStore.selectedCategoryId.set('9');
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith(null);
});
});
describe('Xtream', () => {
function lockProvider(providerId: number): void {
parentalLock.active.set(true);
parentalLock.isXtreamCategoryLocked.mockImplementation(
(_p: string, _t: string, id: number) => id === providerId
);
}
it('clears a selected item whose category the lock store withholds', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
lockProvider(990);
xtreamStore.selectedItem.set({ category_id: 99 });
await service.applyXtream(parentalLock.version());
expect(xtreamStore.reloadCategories).toHaveBeenCalled();
expect(xtreamDataSource.getAllCategories).toHaveBeenCalledWith(
'xtream-1',
'movies'
);
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'vod',
]);
});
it('keeps a detail from a category that is merely hidden, not locked', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
lockProvider(990);
// Row 55 is absent from the (hidden-filtered) visible list but
// exists unlocked in the unfiltered rows.
xtreamStore.selectedItem.set({ category_id: 55 });
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('steps off a selected locked category', async () => {
router.url = '/workspace/xtreams/xtream-1/live';
lockProvider(990);
xtreamStore.selectedCategoryId.set(99);
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'live',
]);
});
it('fails closed when the category rows cannot be read', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
parentalLock.active.set(true);
xtreamDataSource.getAllCategories.mockRejectedValueOnce(
new Error('db')
);
xtreamStore.selectedItem.set({ category_id: 7 });
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
});
it('skips the post-reload checks while unlocked and hands the reloads a publish guard', async () => {
router.url = '/workspace/xtreams/xtream-1/vod';
await service.applyXtream(parentalLock.version());
expect(xtreamStore.withholdCatalog).not.toHaveBeenCalled();
expect(xtreamDataSource.getAllCategories).not.toHaveBeenCalled();
const guard = xtreamStore.reloadCategories.mock.calls[0][0] as
(() => boolean) | undefined;
expect(guard?.()).toBe(true);
parentalLock.version.set(parentalLock.version() + 1);
expect(guard?.()).toBe(false);
});
it('abandons the reloads and checks once the Xtream playlist is switched', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/7';
parentalLock.active.set(true);
xtreamStore.selectedCategoryId.set(7);
parentalLock.isXtreamCategoryLocked.mockReturnValue(true);
let guard: () => boolean = () => true;
(xtreamStore.reloadCategories as jest.Mock).mockImplementationOnce(
async (shouldPublish: () => boolean) => {
guard = shouldPublish;
xtreamStore.playlistId.set('xtream-2');
router.url = '/workspace/xtreams/xtream-2/vod';
}
);
try {
await service.applyXtream(parentalLock.version());
expect(guard()).toBe(false);
expect(xtreamStore.refreshSearchResults).not.toHaveBeenCalled();
expect(
xtreamDataSource.getAllCategories
).not.toHaveBeenCalled();
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
} finally {
xtreamStore.playlistId.set('xtream-1');
}
});
it('re-runs the stored in-portal search after the reload', async () => {
router.url = '/workspace/xtreams/xtream-1/search';
await service.applyXtream(parentalLock.version());
expect(xtreamStore.refreshSearchResults).toHaveBeenCalled();
});
it('clears on relock, synchronously, a detail whose category is not in the visible list', () => {
// Opened through search from a manually hidden category: the
// on-screen list cannot place it, and the unfiltered lookup is
// an awaited read that may hang.
router.url = '/workspace/xtreams/xtream-1/vod/55/900';
xtreamStore.selectedItem.set({ category_id: 55 });
service.failClosedNow();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'vod',
]);
});
it('keeps a detail on relock whose visible category is not locked', () => {
router.url = '/workspace/xtreams/xtream-1/vod/7/900';
xtreamStore.selectedItem.set({ category_id: 7 });
service.failClosedNow();
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
});
it('fails closed synchronously on relock: detail, catalog and search', () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
lockProvider(70);
xtreamStore.selectedItem.set({ category_id: 7 });
service.failClosedNow();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.withholdCatalog).toHaveBeenCalled();
expect(xtreamStore.clearSearchResults).toHaveBeenCalled();
expect(xtreamStore.reloadCategories).not.toHaveBeenCalled();
});
});
});
describe('ParentalLockEnforcementService apply serialization', () => {
it('runs applies one at a time and abandons a result superseded by a newer version', async () => {
const version = signal(0);
let releaseReload: () => void = () => undefined;
const xtreamStore = {
playlistId: signal('xtream-1'),
selectedCategoryId: signal<number | null>(99),
selectedItem: signal(null),
reloadCategories: jest.fn(
() => new Promise<void>((resolve) => (releaseReload = resolve))
),
reloadCachedContent: jest.fn(async () => undefined),
refreshSearchResults: jest.fn(async () => undefined),
withholdCatalog: jest.fn(),
clearSearchResults: jest.fn(),
getCategoriesBySelectedType: jest.fn(() => [] as unknown[]),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
{
provide: ParentalLockService,
useValue: {
version,
active: signal(true),
isXtreamCategoryLocked: jest.fn(() => false),
registerBusyProbe: jest.fn(),
isStalkerCategoryLocked: jest.fn(() => false),
isM3uGroupLocked: jest.fn(() => false),
},
},
{ provide: XtreamStore, useValue: xtreamStore },
{
// No rows: the selected category cannot be placed and
// fails closed once an apply gets to judge it.
provide: XTREAM_DATA_SOURCE,
useValue: { getAllCategories: jest.fn(async () => []) },
},
{
provide: StalkerStore,
useValue: { currentPlaylist: signal(null) },
},
{
provide: Router,
useValue: {
url: '/workspace/xtreams/xtream-1/live',
navigate: jest.fn(),
},
},
{
provide: Store,
useValue: {
selectSignal: () => signal(null),
dispatch: jest.fn(),
},
},
{
provide: PlaybackKeepAwakeService,
useValue: { hasPlayingVideo: () => false },
},
],
});
const service = TestBed.inject(ParentalLockEnforcementService);
TestBed.runInInjectionContext(() => service.start());
TestBed.flushEffects();
// Unlock: the reload is held open...
version.set(1);
TestBed.flushEffects();
await Promise.resolve();
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
// ...and "Lock now" arrives meanwhile: no second reload starts yet,
// but the catalog is withheld at once rather than behind the hung
// read.
xtreamStore.withholdCatalog.mockClear();
version.set(2);
TestBed.flushEffects();
expect(xtreamStore.withholdCatalog).toHaveBeenCalledTimes(1);
await Promise.resolve();
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
// The superseded apply must not act on its (unlocked) rows.
releaseReload();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(2);
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
releaseReload();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledTimes(1);
});
});
describe('ParentalLockEnforcementService busy probe', () => {
it('counts playing audio (the radio player) as activity', () => {
let probe: (() => boolean) | undefined;
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{
provide: ParentalLockService,
useValue: {
version: signal(0),
registerBusyProbe: (fn: () => boolean) => (probe = fn),
},
},
{ provide: XtreamStore, useValue: {} },
{ provide: XTREAM_DATA_SOURCE, useValue: {} },
{ provide: StalkerStore, useValue: {} },
{ provide: Router, useValue: { url: '/' } },
{
provide: Store,
useValue: { selectSignal: () => signal(null) },
},
{
provide: PlaybackKeepAwakeService,
useValue: { hasPlayingVideo: () => false },
},
],
});
const service = TestBed.inject(ParentalLockEnforcementService);
TestBed.runInInjectionContext(() => service.start());
expect(probe?.()).toBe(false);
const audio = document.createElement('audio');
Object.defineProperty(audio, 'paused', { value: false });
Object.defineProperty(audio, 'ended', { value: false });
document.body.appendChild(audio);
try {
expect(probe?.()).toBe(true);
} finally {
audio.remove();
}
});
});
@@ -0,0 +1,394 @@
import {
effect,
EnvironmentInjector,
inject,
Injectable,
untracked,
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { ChannelActions, selectActive } from '@iptvnator/m3u-state';
import {
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces';
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
const XTREAM_ROUTE =
/^\/workspace\/xtreams\/([^/?#]+)\/(live|vod|series)(?:\/(\d+))?/;
export const STALKER_ROUTE =
/^\/workspace\/stalker\/([^/?#]+)\/(itv|vod|series|radio)(?:\/([^/?#]+))?/;
/**
* Applies a parental lock change to the parts of the app that hold catalog
* data in memory. The stores and the SQLite worker filter what they READ;
* this service makes them read again and steps off anything that is now
* withheld — a selected category, a playing channel — so a locked category
* cannot stay on screen just because it was opened before the lock.
*/
@Injectable({ providedIn: 'root' })
export class ParentalLockEnforcementService {
private readonly parentalLock = inject(ParentalLockService);
private readonly xtreamStore = inject(XtreamStore);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
private readonly injector = inject(EnvironmentInjector);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly keepAwake = inject(PlaybackKeepAwakeService);
private readonly activeChannel = this.store.selectSignal(selectActive);
private started = false;
private lastVersion = -1;
private applyChain: Promise<void> = Promise.resolve();
private stalkerModule: StalkerEnforcementModule | null = null;
private stalkerModuleLoad: Promise<StalkerEnforcementModule> | null = null;
start(): void {
if (this.started) {
return;
}
this.started = true;
// Playback counts as activity: video through the keep-awake
// tracker, and audio (the radio player) read directly, since the
// keep-awake service deliberately ignores <audio>.
this.parentalLock.registerBusyProbe(
() => this.keepAwake.hasPlayingVideo() || hasPlayingAudio()
);
// The Stalker step is loaded as soon as a Stalker route opens, so a
// relock there can run it synchronously instead of awaiting a chunk.
this.router.events?.subscribe((event) => {
if (
event instanceof NavigationEnd &&
STALKER_ROUTE.test(event.urlAfterRedirects)
) {
void this.loadStalker().catch(() => undefined);
}
});
// Any path that makes a channel active (numeric zapping, next /
// previous, remote commands, a stale list) is checked here: a
// channel of a locked group is reset as soon as it becomes active.
effect(() => {
this.activeChannel();
untracked(() => {
if (this.parentalLock.active()) {
this.applyM3u();
}
});
});
effect(() => {
const version = this.parentalLock.version();
untracked(() => {
if (version === this.lastVersion) {
return;
}
const first = this.lastVersion === -1;
this.lastVersion = version;
if (first) {
return;
}
// Fail closed NOW, ahead of the serialized queue: an earlier
// apply may still be waiting on a slow (or hung) read, and
// the catalog, details and playback read while unlocked must
// not stay usable until it settles.
if (this.parentalLock.active()) {
this.failClosedNow();
}
this.scheduleApply();
});
});
}
/**
* Applies run one at a time. `ElectronXtreamDataSource` shares in-flight
* reads per playlist and type, so an unlock refresh still running when
* "Lock now" arrives would hand the relock its unfiltered rows. Each
* apply also abandons its result once a newer version exists, leaving
* the queued apply to read the latest state.
*/
private scheduleApply(): void {
this.applyChain = this.applyChain
.then(() => this.apply())
.catch((error) => {
console.error(
'Failed to apply the parental lock change.',
error
);
});
}
/**
* The synchronous half of a relock: M3U channel, Stalker selection (its
* step is lazy, so it runs as soon as the chunk is there), the Xtream
* detail the lock store already places in a locked category, the
* catalog lists and the stored search. The queued apply then reloads
* the filtered rows and repeats the checks against them.
*/
private failClosedNow(): void {
this.applyM3u();
this.failClosedStalkerNow();
const playlistId = this.xtreamStore.playlistId?.();
if (!playlistId) {
return;
}
this.stepOffLockedXtreamSelection(
playlistId,
XTREAM_ROUTE.exec(this.router.url)
);
this.xtreamStore.withholdCatalog?.();
this.xtreamStore.clearSearchResults?.();
}
private async apply(): Promise<void> {
const version = this.parentalLock.version();
// The synchronous surfaces first: an M3U channel or a Stalker
// selection must not keep playing behind a slow Xtream database or
// provider reload — the Xtream store stays populated after leaving
// that portal, so its reload runs on every apply.
this.applyM3u();
await this.applyStalker();
await this.applyXtream(version);
}
private async applyXtream(version: number): Promise<void> {
const playlistId = this.xtreamStore.playlistId?.();
if (!playlistId) {
return;
}
// A playlist switch retires the apply as a newer lock version
// does: the store now holds another portal, whose own load reads
// under the current lock state.
const shouldPublish = (): boolean =>
this.parentalLock.version() === version &&
this.xtreamStore.playlistId?.() === playlistId;
await this.xtreamStore.reloadCategories(shouldPublish);
await this.xtreamStore.reloadCachedContent(shouldPublish);
if (!shouldPublish()) {
return;
}
// Stored in-portal search results are a separate array the search
// page renders directly; re-run the search so it reads filtered.
await this.xtreamStore.refreshSearchResults?.();
if (!shouldPublish() || !this.parentalLock.active()) {
return;
}
await this.stepOffWithheldXtreamSelection(playlistId, shouldPublish);
}
/**
* Post-reload check of the selected Xtream category and item, judged by
* the LOCK STORE through the unfiltered category rows (hidden and locked
* ones included): the reloaded list also omits categories the user
* merely hid, which are not parental-locked. The item is judged on its
* own category — opened from "All", recently added or search it has no
* selected category to vanish with. Rows that cannot be read fail
* closed.
*/
private async stepOffWithheldXtreamSelection(
playlistId: string,
shouldPublish: () => boolean
): Promise<void> {
const match = XTREAM_ROUTE.exec(this.router.url);
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
if (!categoryType) {
return;
}
const rows = await this.xtreamDataSource
.getAllCategories(playlistId, categoryType)
.catch(() => null);
if (!shouldPublish()) {
return;
}
const isWithheld = (categoryId: unknown): boolean => {
const id = Number(categoryId);
if (
categoryId === null ||
categoryId === undefined ||
!Number.isFinite(id)
) {
return false;
}
const row = rows?.find((candidate) => candidate.id === id);
return (
!row ||
this.parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
row.xtream_id
)
);
};
const selectedItem = this.xtreamStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const itemWithheld = isWithheld(selectedItem?.category_id);
const categoryWithheld = isWithheld(
this.xtreamStore.selectedCategoryId()
);
if (!itemWithheld && !categoryWithheld) {
return;
}
this.xtreamStore.setSelectedItem(null);
if (categoryWithheld) {
this.xtreamStore.setSelectedCategory(null);
}
if (match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'xtreams',
match[1],
match[2],
]);
}
}
/**
* Clears a selected Xtream item whose category the lock store already
* says is locked. Electron rows carry the SQLite category row id; the
* category list still on screen maps it to the provider id the store is
* keyed by, the PWA carries the provider id directly. An item whose
* category is not in that list (a manually hidden category, opened
* through search) cannot be judged without an awaited read that may
* hang, so it is cleared too: fail closed.
*/
private stepOffLockedXtreamSelection(
playlistId: string,
match: RegExpExecArray | null
): void {
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
const selectedItem = this.xtreamStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const categoryId = Number(selectedItem?.category_id);
if (!categoryType || !selectedItem || !Number.isFinite(categoryId)) {
return;
}
const category = this.xtreamStore
.getCategoriesBySelectedType()
.find(
(candidate) =>
Number((candidate as { id?: number }).id) === categoryId ||
Number(
(candidate as { category_id?: string }).category_id
) === categoryId
) as { xtream_id?: number; category_id?: string } | undefined;
const providerId = Number(category?.xtream_id ?? category?.category_id);
if (
category &&
Number.isFinite(providerId) &&
!this.parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
providerId
)
) {
return;
}
this.xtreamStore.setSelectedItem(null);
if (match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'xtreams',
match[1],
match[2],
]);
}
}
/** The dynamic import; a field so specs can substitute it. */
loadStalkerEnforcement = (): Promise<StalkerEnforcementModule> =>
import('./parental-lock-stalker-enforcement');
/** Loads the Stalker step once; a failed load may be retried. */
private loadStalker(): Promise<StalkerEnforcementModule> {
this.stalkerModuleLoad ??= this.loadStalkerEnforcement().then(
(module) => (this.stalkerModule = module),
(error: unknown) => {
this.stalkerModuleLoad = null;
throw error;
}
);
return this.stalkerModuleLoad;
}
/**
* The relock's Stalker step, synchronously: run it when the chunk is
* there, otherwise leave the Stalker route at once (its route session
* clears the selection and the live layout stops playback) rather than
* wait for a chunk that may be slow or never arrive.
*/
private failClosedStalkerNow(): void {
if (!STALKER_ROUTE.test(this.router.url)) {
return;
}
if (!this.stalkerModule) {
void this.router.navigate(['/workspace', 'sources']);
return;
}
this.stalkerModule.applyParentalLockToStalker(
this.injector,
this.parentalLock,
this.router
);
}
/**
* The Stalker data layer stays off the initial path: the step is loaded
* only while a Stalker route is open. Outside one there is nothing to
* step off — the Stalker route session clears the selection (and the
* live layout its playback) when the route is left.
*/
private async applyStalker(): Promise<void> {
if (!STALKER_ROUTE.test(this.router.url)) {
return;
}
let module: StalkerEnforcementModule;
try {
module = await this.loadStalker();
} catch (error) {
// Fail closed (e.g. a stale PWA page whose chunk is gone): leave
// the Stalker route. Its route session clears the selection and
// the live layout stops playback on the way out, so no locked
// channel keeps playing; the Xtream step still runs afterwards.
console.error(
'The parental lock Stalker step could not be loaded.',
error
);
void this.router.navigate(['/workspace', 'sources']);
return;
}
module.applyParentalLockToStalker(
this.injector,
this.parentalLock,
this.router
);
}
private applyM3u(): void {
const channel = this.activeChannel();
const playlistId = this.activeM3uPlaylistId();
if (!channel || !playlistId) {
return;
}
const groupTitle = channel.group?.title ?? '';
if (this.parentalLock.isM3uGroupLocked(playlistId, groupTitle)) {
this.store.dispatch(ChannelActions.resetActiveChannel());
}
}
private activeM3uPlaylistId(): string | null {
const match = /^\/workspace\/playlists\/([^/?#]+)/.exec(
this.router.url
);
return match ? decodeURIComponent(match[1]) : null;
}
}
type StalkerEnforcementModule =
typeof import('./parental-lock-stalker-enforcement');
function hasPlayingAudio(): boolean {
return Array.from(document.querySelectorAll('audio')).some(
(audio) => !audio.paused && !audio.ended
);
}
@@ -0,0 +1,8 @@
/**
* Lazy boundary for the parental-lock prompt: the prompt service imports
* this file dynamically, so the PIN dialog and the @angular/forms and
* Material form-field modules it brings stay off the initial path. The
* boundary is a local file backed by a file-level alias, like the other
* root-shell lazy dialogs (see docs/architecture/nx-workspace-boundaries.md).
*/
export { ParentalLockPinDialogComponent } from '@iptvnator/ui/components/parental-lock-pin-dialog';
@@ -0,0 +1,44 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { AppParentalLockPromptService } from './parental-lock-prompt.service';
describe('AppParentalLockPromptService', () => {
const dialog = { open: jest.fn() };
let service: AppParentalLockPromptService;
beforeEach(() => {
jest.clearAllMocks();
TestBed.configureTestingModule({
providers: [
AppParentalLockPromptService,
{ provide: MatDialog, useValue: dialog },
],
});
service = TestBed.inject(AppParentalLockPromptService);
});
it('loads the PIN dialog on demand and returns the entered PIN', async () => {
const open = jest.fn(() => ({ afterClosed: () => of('1234') }));
service.loadPinDialog = jest.fn(async () => ({
ParentalLockPinDialogComponent: { open },
})) as never;
await expect(service.requestPin({ mode: 'set' })).resolves.toBe('1234');
expect(open).toHaveBeenCalledWith(
dialog,
expect.objectContaining({ mode: 'set' })
);
});
it('treats a failed dialog chunk load as a cancelled prompt', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
service.loadPinDialog = jest.fn(async () => {
throw new Error('ChunkLoadError');
}) as never;
await expect(
service.requestPin({ mode: 'unlock' })
).resolves.toBeNull();
});
});
@@ -0,0 +1,62 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { firstValueFrom } from 'rxjs';
import {
PARENTAL_LOCK_PROMPT,
ParentalLockPrompt,
ParentalLockPromptRequest,
} from '@iptvnator/services';
/**
* Application implementation of the parental lock prompt: the shared PIN
* dialog from `@iptvnator/ui/components`, which the data-access lock
* service cannot import itself. The dialog is loaded on first use (the
* service is constructed at startup, the dialog is not needed until a PIN
* is asked for).
*/
@Injectable({ providedIn: 'root' })
export class AppParentalLockPromptService implements ParentalLockPrompt {
private readonly dialog = inject(MatDialog);
/** The dynamic import; a field so specs can substitute it. */
loadPinDialog = () => import('./parental-lock-pin-dialog.lazy');
async requestPin(
request: ParentalLockPromptRequest
): Promise<string | null> {
let module: Awaited<ReturnType<typeof this.loadPinDialog>>;
try {
module = await this.loadPinDialog();
} catch (error) {
// A failed chunk load reads as a cancelled prompt: the gate it
// guards stays closed.
console.error(
'The parental lock PIN dialog could not be loaded.',
error
);
return null;
}
const dialogRef = module.ParentalLockPinDialogComponent.open(
this.dialog,
{
mode: request.mode,
verify: request.verify,
throttle: request.throttle,
titleKey: request.titleKey,
descriptionKey: request.descriptionKey,
}
);
const pin = await firstValueFrom(dialogRef.afterClosed());
return typeof pin === 'string' ? pin : null;
}
}
export function provideParentalLockPrompt(): Provider[] {
return [
AppParentalLockPromptService,
{
provide: PARENTAL_LOCK_PROMPT,
useExisting: AppParentalLockPromptService,
},
];
}
@@ -0,0 +1,69 @@
import { Injector } from '@angular/core';
import { Router } from '@angular/router';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { toParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
import { STALKER_ROUTE } from './parental-lock-enforcement.service';
/**
* The Stalker step of `ParentalLockEnforcementService`, in its own file so
* the enforcement service can import it dynamically: a static import of the
* Stalker store would put the whole Stalker data layer back on the initial
* path (see docs/architecture/nx-workspace-boundaries.md).
*/
export function applyParentalLockToStalker(
injector: Injector,
parentalLock: ParentalLockService,
router: Router
): void {
const stalkerStore = injector.get(StalkerStore);
const playlist = stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
if (!playlistId) {
return;
}
const contentType = toParentalLockStalkerCategoryType(
stalkerStore.selectedContentType()
);
if (!contentType) {
return;
}
const selectedCategoryId = stalkerStore.selectedCategoryId();
const categoryWithheld =
!!selectedCategoryId &&
parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedCategoryId
);
// An item opened from "All" (`*`) or search has its own genre to be
// judged by; the list dropping its row is not enough. Live and radio
// rows carry that genre in `tv_genre_id`, VOD and series rows in
// `category_id` (the store's withheld filter applies the same rule).
const selectedItem = stalkerStore.selectedItem?.() as {
category_id?: string | number;
tv_genre_id?: string | number;
} | null;
const itemCategoryId =
contentType === 'itv' || contentType === 'radio'
? selectedItem?.tv_genre_id
: selectedItem?.category_id;
const itemWithheld =
!!selectedItem &&
parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
itemCategoryId
);
if (!categoryWithheld && !itemWithheld) {
return;
}
stalkerStore.clearSelectedItem();
if (categoryWithheld) {
stalkerStore.setSelectedCategory(null);
}
const match = STALKER_ROUTE.exec(router.url);
if (match && match[1] === playlistId) {
void router.navigate(['/workspace', 'stalker', match[1], match[2]]);
}
}
@@ -72,6 +72,11 @@ export class PlaybackKeepAwakeService {
this.sync();
};
/** Whether any tracked `<video>` is currently playing. */
hasPlayingVideo(): boolean {
return this.playingVideos.size > 0;
}
start(): void {
if (this.started) {
return;
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util';
import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util/playlist-file-import';
import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces';
type PlaylistOpenRequestBridge = {
@@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core';
import {
PORTAL_PLAYBACK_POSITIONS,
PortalPlaybackPositions,
} from '@iptvnator/portal/shared/util';
} from '@iptvnator/portal/shared/util/tokens';
import {
PlaybackPositionRuntimeBridgeService,
RuntimeCapabilitiesService,
+1 -1
View File
@@ -34,7 +34,7 @@ import {
logPortalDebugEvent,
logPortalDebugRequest,
createLogger,
} from '@iptvnator/portal/shared/util';
} from '@iptvnator/portal/shared/util/logger';
import { getRuntimeBackendUrl } from './runtime-config';
/**
@@ -1,7 +1,7 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util';
import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util/tokens';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspaceAccountInfoData,
@@ -5,7 +5,7 @@ import {
selectIsEpgAvailable,
} from '@iptvnator/m3u-state';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaylistBackupService } from '@iptvnator/services';
import { PlaylistBackupService } from '@iptvnator/services/playlist-backup';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockProvider } from 'ng-mocks';
@@ -3,11 +3,11 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
PlaylistBackupImportSummary,
PlaylistBackupService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
} from '@iptvnator/services/playlist-backup';
import { SettingsSnackbarService } from './settings-snackbar.service';
@Injectable()
@@ -19,6 +19,7 @@ import {
normalizeEmbeddedMpvExtraOptions,
normalizeExternalPlayerArguments,
normalizeEpgOffsetMinutes,
normalizeParentalLockRelockMinutes,
normalizeStartupWindowMode,
Settings,
StartupBehavior,
@@ -228,6 +229,12 @@ export function createSettingsFromFormValue(
enabled: value.tmdb?.enabled ?? DEFAULT_TMDB_SETTINGS.enabled,
apiKey: value.tmdb?.apiKey?.trim() ?? '',
},
// Not on the form: the parental lock section applies its changes
// immediately (they gate on the PIN), so Save must not undo them.
parentalLockEnabled: currentSettings.parentalLockEnabled === true,
parentalLockRelockMinutes: normalizeParentalLockRelockMinutes(
currentSettings.parentalLockRelockMinutes
),
};
}
@@ -94,6 +94,7 @@ describe('buildSettingsSectionNavItems', () => {
'dashboard',
'remote-control',
'tmdb',
'parental',
'backup',
'reset',
'about',
@@ -0,0 +1,90 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import {
PARENTAL_LOCK_RELOCK_MINUTES_OPTIONS,
ParentalLockRelockMinutes,
} from '@iptvnator/shared/interfaces';
import { SettingsSnackbarService } from './settings-snackbar.service';
/**
* Settings → Parental lock. Every control here applies immediately: the
* enable toggle and the PIN actions gate on the PIN dialog, so staging them
* in the shared dirty form (Save/Discard) would make no sense — a
* "discarded" PIN would already have been typed.
*/
@Injectable()
export class SettingsParentalLockFacade {
private readonly parentalLock = inject(ParentalLockService);
private readonly snackbar = inject(SettingsSnackbarService);
private readonly translate = inject(TranslateService);
readonly enabled = this.parentalLock.enabled;
readonly unlocked = this.parentalLock.unlocked;
readonly hasPin = this.parentalLock.hasPin;
readonly relockMinutes = this.parentalLock.relockMinutes;
readonly relockOptions = PARENTAL_LOCK_RELOCK_MINUTES_OPTIONS;
readonly busy = signal(false);
readonly canLockNow = computed(() => this.enabled() && this.unlocked());
constructor() {
void this.parentalLock.initialize();
}
async toggleEnabled(enabled: boolean): Promise<void> {
await this.run(async () => {
if (enabled) {
if (await this.parentalLock.setupPin()) {
this.notify('SETTINGS.PARENTAL_LOCK.ENABLED_TOAST');
}
return;
}
if (await this.parentalLock.disable()) {
this.notify('SETTINGS.PARENTAL_LOCK.DISABLED_TOAST');
}
});
}
async changePin(): Promise<void> {
await this.run(async () => {
if (await this.parentalLock.changePin()) {
this.notify('SETTINGS.PARENTAL_LOCK.PIN_CHANGED_TOAST');
}
});
}
async setRelockMinutes(minutes: ParentalLockRelockMinutes): Promise<void> {
await this.run(async () => {
if (!(await this.parentalLock.setRelockMinutes(minutes))) {
this.snackbar.storageFailure('save');
}
});
}
lockNow(): void {
this.parentalLock.lock();
this.notify('SETTINGS.PARENTAL_LOCK.LOCKED_TOAST');
}
async unlock(): Promise<void> {
await this.run(async () => {
await this.parentalLock.requestUnlock();
});
}
private notify(key: string): void {
this.snackbar.open(this.translate.instant(key));
}
private async run(action: () => Promise<unknown>): Promise<void> {
if (this.busy()) {
return;
}
this.busy.set(true);
try {
await action();
} finally {
this.busy.set(false);
}
}
}
@@ -0,0 +1,145 @@
<section class="settings-group" id="parental">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>family_restroom</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}</h3>
<p>{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}</p>
</div>
</header>
<div class="setting-item" data-setting-id="parental-lock-enable">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE' | translate }}</h4>
<p>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
data-test-id="parental-lock-enabled"
[checked]="enabled()"
[disabled]="busy()"
(change)="onToggleEnabled($event)"
[attr.aria-label]="'SETTINGS.PARENTAL_LOCK.ENABLE' | translate"
></mat-slide-toggle>
</div>
</div>
@if (enabled()) {
<div class="setting-item" data-setting-id="parental-lock-status">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}</h4>
<p>
{{
(unlocked()
? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED'
: 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED'
) | translate
}}
</p>
</div>
<div class="setting-item__control">
@if (unlocked()) {
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-lock-now"
[disabled]="busy()"
(click)="lockNow.emit()"
>
<mat-icon>lock</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.LOCK_NOW' | translate }}
</button>
} @else {
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-unlock"
[disabled]="busy()"
(click)="unlock.emit()"
>
<mat-icon>lock_open</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.UNLOCK' | translate }}
</button>
}
</div>
</div>
<div class="setting-item" data-setting-id="parental-lock-relock">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION' | translate
}}
</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.PARENTAL_LOCK.RELOCK' | translate
}}</mat-label>
<mat-select
data-test-id="parental-lock-relock"
[value]="relockMinutes()"
[disabled]="busy() || !unlocked()"
(selectionChange)="
relockMinutesChanged.emit($event.value)
"
>
@for (option of relockOptions(); track option) {
<mat-option [value]="option">
{{
relockLabelKey(option)
| translate: { minutes: option }
}}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="setting-item" data-setting-id="parental-lock-change-pin">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control">
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-change-pin"
[disabled]="busy() || !hasPin()"
(click)="changePin.emit()"
>
<mat-icon>password</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}
</button>
</div>
</div>
<div class="setting-item" data-setting-id="parental-lock-how-to">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION'
| translate
}}
</p>
</div>
</div>
}
<div class="setting-item">
<div class="setting-item__meta">
<p>{{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}</p>
</div>
</div>
</section>
@@ -0,0 +1,46 @@
import { TestBed } from '@angular/core/testing';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsParentalLockSectionComponent } from './settings-parental-section.component';
describe('SettingsParentalLockSectionComponent', () => {
it('keeps the enable switch on the saved state until the PIN action succeeds', async () => {
await TestBed.configureTestingModule({
imports: [
SettingsParentalLockSectionComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
const fixture = TestBed.createComponent(
SettingsParentalLockSectionComponent
);
fixture.componentRef.setInput('enabled', false);
fixture.componentRef.setInput('unlocked', false);
fixture.componentRef.setInput('hasPin', false);
fixture.componentRef.setInput('relockMinutes', 15);
fixture.componentRef.setInput('relockOptions', [0, 15]);
fixture.detectChanges();
const requested = jest.fn();
fixture.componentInstance.toggleEnabled.subscribe(requested);
const toggle = fixture.debugElement.query(By.directive(MatSlideToggle))
.componentInstance as MatSlideToggle;
const button = fixture.nativeElement.querySelector(
'[data-test-id="parental-lock-enabled"] button'
) as HTMLButtonElement;
// The set-PIN prompt is then cancelled: `enabled` never changes.
button.click();
fixture.detectChanges();
expect(requested).toHaveBeenCalledWith(true);
expect(toggle.checked).toBe(false);
// The PIN was set: the switch follows the saved state.
fixture.componentRef.setInput('enabled', true);
fixture.detectChanges();
expect(toggle.checked).toBe(true);
});
});
@@ -0,0 +1,59 @@
import { Component, input, output, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import {
MatSlideToggleChange,
MatSlideToggleModule,
} from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
@Component({
selector: 'app-settings-parental-lock-section',
imports: [
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatSelectModule,
MatSlideToggleModule,
TranslateModule,
],
templateUrl: './settings-parental-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
})
export class SettingsParentalLockSectionComponent {
readonly enabled = input.required<boolean>();
readonly unlocked = input.required<boolean>();
readonly hasPin = input.required<boolean>();
readonly relockMinutes = input.required<ParentalLockRelockMinutes>();
readonly relockOptions =
input.required<readonly ParentalLockRelockMinutes[]>();
readonly busy = input(false);
readonly toggleEnabled = output<boolean>();
readonly changePin = output<void>();
readonly relockMinutesChanged = output<ParentalLockRelockMinutes>();
readonly lockNow = output<void>();
readonly unlock = output<void>();
/**
* The switch only REQUESTS the change: enabling and disabling both go
* through the PIN, which may be cancelled. It is put back to the saved
* state at once and follows `enabled()` when the action succeeds;
* otherwise the slide toggle would keep showing the state it flipped to
* (`[checked]` is not re-applied while the bound value is unchanged).
*/
onToggleEnabled(event: MatSlideToggleChange): void {
event.source.checked = this.enabled();
this.toggleEnabled.emit(event.checked);
}
relockLabelKey(minutes: ParentalLockRelockMinutes): string {
return minutes === 0
? 'SETTINGS.PARENTAL_LOCK.RELOCK_NEVER'
: 'SETTINGS.PARENTAL_LOCK.RELOCK_MINUTES';
}
}
@@ -6,7 +6,11 @@ import {
selectAllPlaylistsMeta,
selectIsEpgAvailable,
} from '@iptvnator/m3u-state';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
ParentalLockService,
DatabaseService,
PlaylistsService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { Store } from '@ngrx/store';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
@@ -59,6 +63,9 @@ describe('SettingsPlaylistResetFacade', () => {
MockProvider(PlaylistsService, {
removeAll: jest.fn().mockReturnValue(of(undefined)),
}),
MockProvider(ParentalLockService, {
clearAllLocks: jest.fn().mockResolvedValue(true),
}),
provideMockStore({
selectors: [
{ selector: selectAllPlaylistsMeta, value: [] },
@@ -218,6 +225,9 @@ describe('SettingsPlaylistResetFacade', () => {
expect(playlistsService.removeAll).toHaveBeenCalled();
expect(databaseService.deleteAllPlaylists).not.toHaveBeenCalled();
expect(
TestBed.inject(ParentalLockService).clearAllLocks
).toHaveBeenCalled();
expect(dispatchSpy).toHaveBeenCalledWith(
PlaylistActions.removeAllPlaylists()
);
@@ -235,6 +245,10 @@ describe('SettingsPlaylistResetFacade', () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(facade.isRemovingAllPlaylists()).toBe(false);
// Nothing was deleted, so the locks stay.
expect(
TestBed.inject(ParentalLockService).clearAllLocks
).not.toHaveBeenCalled();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.PLAYLISTS_REMOVE_FAILED',
undefined,
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom, take } from 'rxjs';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import {
ParentalLockService,
DatabaseService,
DbOperationEvent,
PlaylistsService,
@@ -24,6 +25,7 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
@Injectable()
export class SettingsPlaylistResetFacade {
private readonly databaseService = inject(DatabaseService);
private readonly parentalLock = inject(ParentalLockService);
private readonly matDialog = inject(MatDialog);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
@@ -107,6 +109,12 @@ export class SettingsPlaylistResetFacade {
if (!deleted) {
throw new Error('Delete all playlists returned success=false');
}
// The playlists are gone; their locks must not linger in the
// lock store (the deletion itself already succeeded, so a failed
// clear is only logged).
if (!(await this.parentalLock.clearAllLocks())) {
console.warn('Clearing the parental locks failed.');
}
this.store.dispatch(PlaylistActions.removeAllPlaylists());
this.settingsSnackbar.open(
@@ -12,7 +12,7 @@ import {
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
import {
SettingsRevealRequest,
SettingsSearchEntry,
@@ -379,6 +379,10 @@ describe('SettingsComponent form', () => {
...component.settingsForm.value,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
// Parental lock settings are not on the form; Save
// carries the current values through unchanged.
parentalLockEnabled: false,
parentalLockRelockMinutes: 15,
},
{ retryEpgCleanup: false }
);
@@ -386,6 +390,8 @@ describe('SettingsComponent form', () => {
...component.settingsForm.value,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
parentalLockEnabled: false,
parentalLockRelockMinutes: 15,
});
});
@@ -78,6 +78,23 @@
@case ('tmdb') {
<app-settings-tmdb-section [form]="settingsForm" />
}
@case ('parental') {
<app-settings-parental-lock-section
[enabled]="parentalLock.enabled()"
[unlocked]="parentalLock.unlocked()"
[hasPin]="parentalLock.hasPin()"
[relockMinutes]="parentalLock.relockMinutes()"
[relockOptions]="parentalLock.relockOptions"
[busy]="parentalLock.busy()"
(toggleEnabled)="parentalLock.toggleEnabled($event)"
(changePin)="parentalLock.changePin()"
(relockMinutesChanged)="
parentalLock.setRelockMinutes($event)
"
(lockNow)="parentalLock.lockNow()"
(unlock)="parentalLock.unlock()"
/>
}
@case ('backup') {
<app-settings-backup-section
[isPwa]="isPwa"
@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { Router } from '@angular/router';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
import {
SETTINGS_SEARCH_ENTRIES,
SettingsSearchEntry,
@@ -16,7 +16,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
EpgSourceReconciliationError,
@@ -46,6 +46,8 @@ import {
SETTINGS_UPDATE_CHANNEL_OPTIONS,
SETTINGS_THEME_OPTIONS,
} from './settings-options';
import { SettingsParentalLockFacade } from './settings-parental-lock.facade';
import { SettingsParentalLockSectionComponent } from './settings-parental-section.component';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
@@ -93,6 +95,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
SettingsDashboardSectionComponent,
SettingsEpgSectionComponent,
SettingsGeneralSectionComponent,
SettingsParentalLockSectionComponent,
SettingsPlaybackSectionComponent,
SettingsRemoteControlSectionComponent,
SettingsResetSectionComponent,
@@ -107,6 +110,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
SettingsEmbeddedMpvFacade,
SettingsEpgFacade,
SettingsFormFacade,
SettingsParentalLockFacade,
SettingsPlaylistResetFacade,
SettingsRemoteControlFacade,
SettingsSearchFacade,
@@ -122,6 +126,7 @@ export class SettingsComponent
readonly embeddedMpv = inject(SettingsEmbeddedMpvFacade);
readonly epg = inject(SettingsEpgFacade);
readonly form = inject(SettingsFormFacade);
readonly parentalLock = inject(SettingsParentalLockFacade);
readonly playlistReset = inject(SettingsPlaylistResetFacade);
readonly remoteControl = inject(SettingsRemoteControlFacade);
readonly search = inject(SettingsSearchFacade);
@@ -34,9 +34,9 @@ import {
import {
DatabaseService,
DataService,
PlaylistBackupService,
PlaylistsService,
} from '@iptvnator/services';
import { PlaylistBackupService } from '@iptvnator/services/playlist-backup';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "إعادة الاتصال بالبث المنقطع تلقائيًا",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "عند انقطاع بث قيد التشغيل، يعيد المشغّل المدمج تحميله بفواصل زمنية متزايدة، حتى 6 محاولات، قبل أن يتوقف. أما البث الذي لم يبدأ أصلًا فلا يزال يحتاج إلى إعادة المحاولة يدويًا.",
"VOD_AUTO_FAILOVER": "التبديل التلقائي للمصدر عند فشل التشغيل",
"VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة."
"VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "داكن",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "تبديل المشغل إلى {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "تعيين {{name}} كمشغل الفيديو النشط",
"SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "جارٍ تحديث قائمة التشغيل",
"XTREAM_REFRESH_COLLECTING_DATA": "جارٍ الحفاظ على بيانات مكتبتك...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "عاود الاتصال أوتوماتيكياً بالبث اللي تقطع",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "ملي شي بث خدام كيتقطع، المشغل المدمج كيعاود يشرجو بفواصل كتزيد شوية بشوية، حتى 6 محاولات، قبل ما يوقف. البث اللي عمرو ما بدا كيبقى محتاج تحاول مرة أخرى يدوياً.",
"VOD_AUTO_FAILOVER": "بدل المصدر أوتوماتيكياً عند خطأ فالتشغيل",
"VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف."
"VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "داكن",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "بدل المشغل ل{{name}}",
"SWITCH_PLAYER_DESCRIPTION": "حدد {{name}} كمشغل الفيديو النشط",
"SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "كنحدّثو البلايليست",
"XTREAM_REFRESH_COLLECTING_DATA": "كنحافظو على معطيات المكتبة ديالك...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Аўтаматычна перападключацца да абарваных патокаў",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Калі паток, які прайграецца, абрываецца, убудаваны прайгравальнік перазагружае яго з усё большымі паўзамі, да 6 спроб, перш чым спыніцца. Паток, які так і не запусціўся, па-ранейшаму патрабуе ручнога «Паўтарыць».",
"VOD_AUTO_FAILOVER": "Аўтапераключэнне крыніцы пры памылцы прайгравання",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Цёмная",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Пераключыць прайгравальнік на {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Усталяваць {{name}} як актыўны відэапрайгравальнік",
"SWITCH_PLAYER_FEEDBACK": "Прайгравальнік усталяваны: {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Паказаць даныя акаўнта Stalker-партала"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Паказаць даныя акаўнта Stalker-партала",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Абнаўленне плэйліста",
"XTREAM_REFRESH_COLLECTING_DATA": "Захаванне даных вашай бібліятэкі...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Unterbrochene Streams automatisch neu verbinden",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Wenn ein laufender Stream unterbrochen wird, lädt der eingebettete Player ihn in bis zu 6 Versuchen mit immer längeren Wartezeiten neu, bevor er aufgibt. Ein Stream, der gar nicht erst gestartet ist, erfordert weiterhin ein manuelles „Erneut versuchen“.",
"VOD_AUTO_FAILOVER": "Quelle bei Wiedergabefehler automatisch wechseln",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann.",
"NAV_PARENTAL": "Kindersicherung",
"PARENTAL_LOCK": {
"TITLE": "Kindersicherung",
"DESCRIPTION": "Verbirgt ausgewählte Kategorien hinter einer PIN. Gesperrte Kategorien, ihre Sender und Titel werden aus der gesamten App entfernt, bis die PIN eingegeben wird.",
"ENABLE": "Kindersicherung aktivieren",
"ENABLE_DESCRIPTION": "Beim Aktivieren wird eine neue PIN abgefragt. Beim Deaktivieren die aktuelle PIN; die Sperren bleiben erhalten.",
"STATUS": "Aktueller Zustand",
"STATUS_LOCKED": "Gesperrt – gesperrte Kategorien sind verborgen.",
"STATUS_UNLOCKED": "Entsperrt – gesperrte Kategorien sind sichtbar, bis die App wieder sperrt.",
"LOCK_NOW": "Jetzt sperren",
"UNLOCK": "Entsperren",
"RELOCK": "Erneut sperren nach",
"RELOCK_DESCRIPTION": "Minuten ohne Interaktion, nach denen sich die App wieder sperrt. Wiedergabe zählt als Interaktion. Ein Neustart sperrt immer.",
"RELOCK_NEVER": "Nur beim Neustart der App",
"RELOCK_MINUTES": "{{minutes}} Minuten Inaktivität",
"CHANGE_PIN": "PIN ändern",
"CHANGE_PIN_DESCRIPTION": "Fragt zuerst die aktuelle PIN ab.",
"HOW_TO_LOCK": "Was gesperrt wird",
"HOW_TO_LOCK_DESCRIPTION": "Öffne eine Quelle und wähle „Kategorien verwalten“ (das Einstellungssymbol über der Kategorienliste; in einer M3U-Playlist „Gruppen verwalten“): jede Zeile hat einen Schloss-Schalter, und Stalker bietet „Erwachsene (18+) sperren“ für vom Portal markierte Genres. Rechtsklick auf eine Kategorie oder Gruppe für schnelles Sperren / Entsperren.",
"DISCLAIMER": "Das ist eine Kindersicherung, kein Sicherheitsmerkmal: Die Datendateien der App sind für jeden mit Zugriff auf diesen Computer lesbar.",
"ENABLED_TOAST": "Kindersicherung aktiviert",
"DISABLED_TOAST": "Kindersicherung deaktiviert",
"PIN_CHANGED_TOAST": "PIN geändert",
"LOCKED_TOAST": "Gesperrt"
}
},
"THEMES": {
"DARK_THEME": "Dunkel",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Player auf {{name}} wechseln",
"SWITCH_PLAYER_DESCRIPTION": "{{name}} als aktiven Videoplayer festlegen",
"SWITCH_PLAYER_FEEDBACK": "Player auf {{name}} festgelegt",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Kontodaten des Stalker-Portals anzeigen"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Kontodaten des Stalker-Portals anzeigen",
"PARENTAL_LOCK_NOW_LABEL": "Jetzt sperren",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Kindergesicherte Kategorien wieder verbergen",
"PARENTAL_UNLOCK_LABEL": "Kindersicherungs-PIN eingeben",
"PARENTAL_UNLOCK_DESCRIPTION": "Kindergesicherte Kategorien anzeigen"
},
"XTREAM_REFRESH_TITLE": "Playlist wird aktualisiert",
"XTREAM_REFRESH_COLLECTING_DATA": "Deine Bibliotheksdaten werden gesichert...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Kindersicherungs-PIN eingeben",
"UNLOCK_DESCRIPTION": "Gesperrte Kategorien bleiben verborgen, bis die PIN eingegeben wird.",
"SET_TITLE": "Kindersicherungs-PIN festlegen",
"SET_DESCRIPTION": "Wähle eine PIN, die zum Anzeigen gesperrter Kategorien nötig ist.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "PIN wiederholen",
"PIN_HINT": "{{min}} bis {{max}} Ziffern",
"WRONG_PIN": "Falsche PIN. Bitte erneut versuchen.",
"MISMATCH": "Die beiden PINs stimmen nicht überein.",
"COOLDOWN": "Zu viele Versuche. Warte 30 Sekunden.",
"NO_RECOVERY": "Eine vergessene PIN kann nicht wiederhergestellt werden – nur das Zurücksetzen der App-Daten entfernt sie.",
"SAVE": "PIN speichern",
"UNLOCK": "Entsperren",
"CONFIRM_TITLE": "Kindersicherungs-PIN bestätigen",
"CONFIRM_DESCRIPTION": "Gib die aktuelle PIN ein, um diese Einstellung zu ändern."
},
"LOCKED_COUNT": "Gesperrt",
"LOCK_CATEGORY": "Kategorie sperren",
"UNLOCK_CATEGORY": "Kategorie entsperren",
"LOCK_GROUP": "Gruppe sperren",
"UNLOCK_GROUP": "Gruppe entsperren",
"MANAGE_LOCKS": "Kategorien sperren",
"MANAGE_LOCKS_DESCRIPTION": "Gesperrte Kategorien und ihre Sender verschwinden aus der gesamten App, bis die Kindersicherungs-PIN eingegeben wird.",
"LOCK_CENSORED": "18+ sperren",
"UNLOCK_ALL": "Alle entsperren",
"CENSORED_HINT": "Das Portal kennzeichnet diese Kategorie als Inhalt für Erwachsene",
"SAVE_FAILED": "Die Kategoriesperren konnten nicht gespeichert werden",
"LOCKED_CATEGORIES_ROW": "{{count}} gesperrt",
"UNLOCK_TO_SHOW": "PIN eingeben zum Anzeigen",
"HEADER_LOCK": "Jetzt sperren (Kindersicherung)",
"HEADER_UNLOCK": "Kindersicherungs-PIN eingeben"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Αυτόματη επανασύνδεση σε ροές που διακόπηκαν",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Όταν μια ροή που αναπαράγεται διακοπεί, το ενσωματωμένο πρόγραμμα αναπαραγωγής τη φορτώνει ξανά με αυξανόμενες καθυστερήσεις, έως 6 προσπάθειες, πριν τα παρατήσει. Μια ροή που δεν ξεκίνησε ποτέ εξακολουθεί να χρειάζεται χειροκίνητη Επανάληψη.",
"VOD_AUTO_FAILOVER": "Αυτόματη αλλαγή πηγής σε σφάλμα αναπαραγωγής",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Σκούρο",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Εναλλαγή προγράμματος αναπαραγωγής σε {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Ορισμός του {{name}} ως ενεργό πρόγραμμα αναπαραγωγής βίντεο",
"SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Εμφάνιση στοιχείων λογαριασμού της πύλης Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Εμφάνιση στοιχείων λογαριασμού της πύλης Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Ανανέωση λίστας αναπαραγωγής",
"XTREAM_REFRESH_COLLECTING_DATA": "Διατήρηση των δεδομένων της βιβλιοθήκης σας...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconnect dropped streams automatically",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "When a playing stream drops, the embedded player reloads it with increasing delays, up to 6 attempts, before giving up. A stream that never started still needs a manual Retry.",
"VOD_AUTO_FAILOVER": "Auto-switch source on playback error",
"VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub."
"VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Dark",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Switch player to {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Set {{name}} as the active video player",
"SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Show Stalker portal account details"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Show Stalker portal account details",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Refreshing playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Preserving your library data...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconectar automáticamente los streams interrumpidos",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Cuando un stream en reproducción se interrumpe, el reproductor integrado lo vuelve a cargar con intervalos cada vez más largos, hasta 6 intentos, antes de desistir. Un stream que nunca llegó a iniciarse sigue necesitando un Reintentar manual.",
"VOD_AUTO_FAILOVER": "Cambiar de fuente automáticamente al fallar la reproducción",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Oscuro",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Cambiar reproductor a {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Establecer {{name}} como reproductor de vídeo activo",
"SWITCH_PLAYER_FEEDBACK": "Reproductor establecido a {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar los datos de la cuenta del portal Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar los datos de la cuenta del portal Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Actualizando lista de reproducción",
"XTREAM_REFRESH_COLLECTING_DATA": "Conservando los datos de tu biblioteca...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconnecter automatiquement les flux interrompus",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Lorsqu'un flux en cours de lecture est interrompu, le lecteur intégré le recharge avec des délais croissants, jusqu'à 6 tentatives, avant d'abandonner. Un flux qui n'a jamais démarré doit toujours être relancé manuellement avec « Réessayer ».",
"VOD_AUTO_FAILOVER": "Changer de source automatiquement en cas d'erreur de lecture",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Sombre",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Basculer le lecteur sur {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Définir {{name}} comme lecteur vidéo actif",
"SWITCH_PLAYER_FEEDBACK": "Lecteur défini sur {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Afficher les informations du compte du portail Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Afficher les informations du compte du portail Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Actualisation de la playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Préservation des données de votre bibliothèque...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Megszakadt adatfolyamok automatikus újracsatlakoztatása",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Ha egy lejátszás alatt álló adatfolyam megszakad, a beágyazott lejátszó egyre hosszabb várakozással, legfeljebb 6 próbálkozással tölti be újra, mielőtt feladná. Az el sem indult adatfolyamhoz továbbra is kézi újrapróbálkozás szükséges.",
"VOD_AUTO_FAILOVER": "Automatikus forrásváltás lejátszási hiba esetén",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Sötét",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Lejátszóváltás: {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "{{name}} beállítása aktív videólejátszóként",
"SWITCH_PLAYER_FEEDBACK": "Aktív lejátszó: {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "A Stalker portál fiókadatainak megjelenítése"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "A Stalker portál fiókadatainak megjelenítése",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Lejátszási lista frissítése",
"XTREAM_REFRESH_COLLECTING_DATA": "A médiatár adatainak megőrzése…",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Riconnetti automaticamente gli stream interrotti",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Quando uno stream in riproduzione si interrompe, il lettore integrato lo ricarica a intervalli crescenti, fino a 6 tentativi, prima di rinunciare. Per uno stream che non è mai partito è comunque necessario premere Riprova manualmente.",
"VOD_AUTO_FAILOVER": "Cambia sorgente automaticamente in caso di errore di riproduzione",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Scuro",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Passa al lettore {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Imposta {{name}} come lettore video attivo",
"SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostra i dati dell’account del portale Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostra i dati dell’account del portale Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Aggiornamento playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Conservazione dei dati della tua libreria...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "切断されたストリームに自動的に再接続",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "再生中のストリームが切断されると、埋め込みプレーヤーは間隔を延ばしながら最大 6 回まで再読み込みを試み、その後あきらめます。一度も開始しなかったストリームには、引き続き手動の再試行が必要です。",
"VOD_AUTO_FAILOVER": "再生エラー時にソースを自動切り替え",
"VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。"
"VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "ダーク",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "プレーヤーを{{name}}に切り替え",
"SWITCH_PLAYER_DESCRIPTION": "{{name}}をアクティブなビデオプレーヤーに設定",
"SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalkerポータルのアカウント情報を表示"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalkerポータルのアカウント情報を表示",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "プレイリストを更新中",
"XTREAM_REFRESH_COLLECTING_DATA": "ライブラリデータを保持しています...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "끊긴 스트림 자동 재연결",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "재생 중인 스트림이 끊기면 내장 플레이어가 점점 길어지는 간격으로 최대 6회까지 다시 불러온 뒤 포기합니다. 한 번도 시작되지 않은 스트림은 여전히 수동으로 다시 시도해야 합니다.",
"VOD_AUTO_FAILOVER": "재생 오류 시 소스 자동 전환",
"VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다."
"VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "어둡게",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "플레이어를 {{name}}(으)로 전환",
"SWITCH_PLAYER_DESCRIPTION": "{{name}}을(를) 활성 비디오 플레이어로 설정",
"SWITCH_PLAYER_FEEDBACK": "플레이어가 {{name}}(으)로 설정되었습니다",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker 포털 계정 정보 표시"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker 포털 계정 정보 표시",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "재생목록 새로 고침 중",
"XTREAM_REFRESH_COLLECTING_DATA": "라이브러리 데이터 보존 중...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Weggevallen streams automatisch opnieuw verbinden",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Wanneer een stream tijdens het afspelen wegvalt, laadt de ingebedde speler deze opnieuw met steeds langere tussenpozen, tot maximaal 6 pogingen, voordat hij het opgeeft. Voor een stream die nooit is gestart, blijft handmatig Opnieuw proberen nodig.",
"VOD_AUTO_FAILOVER": "Automatisch van bron wisselen bij een afspeelfout",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Donker",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Speler wisselen naar {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Stel {{name}} in als de actieve videospeler",
"SWITCH_PLAYER_FEEDBACK": "Speler ingesteld op {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Accountgegevens van het Stalker-portaal tonen"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Accountgegevens van het Stalker-portaal tonen",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Afspeellijst vernieuwen",
"XTREAM_REFRESH_COLLECTING_DATA": "Uw bibliotheekgegevens behouden...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Automatycznie wznawiaj przerwane strumienie",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Gdy odtwarzany strumień zostanie przerwany, wbudowany odtwarzacz wczytuje go ponownie z rosnącymi odstępami, maksymalnie 6 prób, zanim się podda. Strumień, który w ogóle się nie uruchomił, nadal wymaga ręcznego użycia „Spróbuj ponownie”.",
"VOD_AUTO_FAILOVER": "Automatyczna zmiana źródła przy błędzie odtwarzania",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Ciemny",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Przełącz odtwarzacz na {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Ustaw {{name}} jako aktywny odtwarzacz wideo",
"SWITCH_PLAYER_FEEDBACK": "Odtwarzacz ustawiony na {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Pokaż dane konta portalu Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Pokaż dane konta portalu Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Odświeżanie listy odtwarzania",
"XTREAM_REFRESH_COLLECTING_DATA": "Zachowywanie danych biblioteki...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconectar streams interrompidos automaticamente",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Quando um stream em reprodução é interrompido, o reprodutor integrado o recarrega com intervalos crescentes, em até 6 tentativas, antes de desistir. Um stream que nunca chegou a iniciar ainda precisa de um \"Tentar novamente\" manual.",
"VOD_AUTO_FAILOVER": "Trocar de fonte automaticamente ao falhar a reprodução",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Escuro",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Alternar reprodutor para {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Definir {{name}} como reprodutor de vídeo ativo",
"SWITCH_PLAYER_FEEDBACK": "Reprodutor definido como {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar dados da conta do portal Stalker"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar dados da conta do portal Stalker",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Atualizando playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Preservando os dados da sua biblioteca...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Автоматически переподключаться при обрыве потока",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Если поток обрывается во время воспроизведения, встроенный плеер перезагружает его с возрастающими задержками (до 6 попыток), после чего прекращает переподключение. Для потока, который так и не запустился, по-прежнему нужно нажать «Повторить» вручную.",
"VOD_AUTO_FAILOVER": "Авто-переключение источника при ошибке воспроизведения",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка.",
"NAV_PARENTAL": "Родительский контроль",
"PARENTAL_LOCK": {
"TITLE": "Родительский контроль",
"DESCRIPTION": "Скрывайте выбранные категории за PIN-кодом. Заблокированные категории, их каналы и фильмы убираются из всего приложения, пока не введён PIN.",
"ENABLE": "Включить родительский контроль",
"ENABLE_DESCRIPTION": "При включении будет запрошен новый PIN. При выключении — текущий PIN; блокировки сохраняются на будущее.",
"STATUS": "Текущее состояние",
"STATUS_LOCKED": "Заблокировано — заблокированные категории скрыты.",
"STATUS_UNLOCKED": "Разблокировано — заблокированные категории видны до следующей блокировки.",
"LOCK_NOW": "Заблокировать сейчас",
"UNLOCK": "Разблокировать",
"RELOCK": "Блокировать снова через",
"RELOCK_DESCRIPTION": "Минуты без действий, после которых приложение блокируется само. Воспроизведение считается действием. Перезапуск приложения всегда блокирует.",
"RELOCK_NEVER": "Только при перезапуске приложения",
"RELOCK_MINUTES": "{{minutes}} минут бездействия",
"CHANGE_PIN": "Сменить PIN",
"CHANGE_PIN_DESCRIPTION": "Сначала запрашивается текущий PIN.",
"HOW_TO_LOCK": "Что блокировать",
"HOW_TO_LOCK_DESCRIPTION": "Откройте источник и нажмите «Управлять категориями» (иконка настройки над списком категорий; в плейлисте M3U — «Управление группами»): у каждой строки есть переключатель замка, а для Stalker доступно «Заблокировать 18+» для жанров, помеченных порталом. Правый клик по категории или группе — быстрое «Заблокировать / Разблокировать».",
"DISCLAIMER": "Это детская блокировка, а не защита: файлы данных приложения доступны любому, у кого есть доступ к этому компьютеру.",
"ENABLED_TOAST": "Родительский контроль включён",
"DISABLED_TOAST": "Родительский контроль выключен",
"PIN_CHANGED_TOAST": "PIN изменён",
"LOCKED_TOAST": "Заблокировано"
}
},
"THEMES": {
"DARK_THEME": "Тёмная",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Переключить плеер на {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Сделать {{name}} активным видеоплеером",
"SWITCH_PLAYER_FEEDBACK": "Плеер переключен на {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Показать данные аккаунта Stalker-портала"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Показать данные аккаунта Stalker-портала",
"PARENTAL_LOCK_NOW_LABEL": "Заблокировать сейчас",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Снова скрыть категории под родительским контролем",
"PARENTAL_UNLOCK_LABEL": "Ввести родительский PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Показать категории под родительским контролем"
},
"XTREAM_REFRESH_TITLE": "Обновление плейлиста",
"XTREAM_REFRESH_COLLECTING_DATA": "Сохраняем данные вашей библиотеки...",
@@ -1872,5 +1901,39 @@
"failed": "Не удалось удалить — выберите для повтора"
},
"DELETE_PROGRESS": "Обработано {{done}} из {{total}} выбранных источников"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Введите родительский PIN",
"UNLOCK_DESCRIPTION": "Заблокированные категории скрыты, пока не введён PIN.",
"SET_TITLE": "Задайте родительский PIN",
"SET_DESCRIPTION": "Придумайте PIN, который потребуется для показа заблокированных категорий.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Повторите PIN",
"PIN_HINT": "от {{min}} до {{max}} цифр",
"WRONG_PIN": "Неверный PIN. Попробуйте ещё раз.",
"MISMATCH": "PIN-коды не совпадают.",
"COOLDOWN": "Слишком много попыток. Подождите 30 секунд.",
"NO_RECOVERY": "Забытый PIN восстановить нельзя — его удаляет только сброс данных приложения.",
"SAVE": "Сохранить PIN",
"UNLOCK": "Разблокировать",
"CONFIRM_TITLE": "Подтвердите родительский PIN",
"CONFIRM_DESCRIPTION": "Введите текущий PIN, чтобы изменить эту настройку."
},
"LOCKED_COUNT": "Заблокировано",
"LOCK_CATEGORY": "Заблокировать категорию",
"UNLOCK_CATEGORY": "Разблокировать категорию",
"LOCK_GROUP": "Заблокировать группу",
"UNLOCK_GROUP": "Разблокировать группу",
"MANAGE_LOCKS": "Заблокировать категории",
"MANAGE_LOCKS_DESCRIPTION": "Заблокированные категории и их каналы исчезают из всего приложения, пока не введён родительский PIN.",
"LOCK_CENSORED": "Заблокировать 18+",
"UNLOCK_ALL": "Разблокировать все",
"CENSORED_HINT": "Портал помечает эту категорию как контент для взрослых",
"SAVE_FAILED": "Не удалось сохранить блокировки категорий",
"LOCKED_CATEGORIES_ROW": "Заблокировано: {{count}}",
"UNLOCK_TO_SHOW": "Введите PIN, чтобы показать",
"HEADER_LOCK": "Заблокировать сейчас (родительский контроль)",
"HEADER_UNLOCK": "Введите родительский PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "Kesilen yayınlara otomatik yeniden bağlan",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Oynatılan bir yayın kesildiğinde gömülü oynatıcı, vazgeçmeden önce yayını giderek artan gecikmelerle en fazla 6 kez yeniden yüklemeyi dener. Hiç başlamamış bir yayın için yine de Tekrar dene’ye el ile basmanız gerekir.",
"VOD_AUTO_FAILOVER": "Oynatma hatasında kaynağı otomatik değiştir",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir."
"VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir.",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "Koyu",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "Oynatıcıyı {{name}} olarak değiştir",
"SWITCH_PLAYER_DESCRIPTION": "{{name}} oynatıcısını etkin video oynatıcı olarak ayarla",
"SWITCH_PLAYER_FEEDBACK": "Oynatıcı {{name}} olarak ayarlandı",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker portalı hesap bilgilerini göster"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker portalı hesap bilgilerini göster",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "Oynatma listesi yenileniyor",
"XTREAM_REFRESH_COLLECTING_DATA": "Kitaplık verileriniz korunuyor...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "自动重连中断的流",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "正在播放的流中断时,嵌入式播放器会以逐渐增加的间隔重新加载,最多尝试 6 次后放弃。从未成功启动的流仍需手动重试。",
"VOD_AUTO_FAILOVER": "播放出错时自动切换播放源",
"VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。"
"VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "深色",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "切换播放器为 {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "将 {{name}} 设为当前视频播放器",
"SWITCH_PLAYER_FEEDBACK": "播放器已设为 {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "显示 Stalker 门户账户信息"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "显示 Stalker 门户账户信息",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "正在刷新播放列表",
"XTREAM_REFRESH_COLLECTING_DATA": "正在保留您的媒体库数据…",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
+65 -2
View File
@@ -551,7 +551,32 @@
"EMBEDDED_MPV_AUTO_RECONNECT": "自動重新連線中斷的串流",
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "播放中的串流中斷時,內嵌播放器會以逐漸拉長的間隔重新載入,最多嘗試 6 次後放棄。從未成功啟動的串流仍需手動重試。",
"VOD_AUTO_FAILOVER": "播放發生錯誤時自動切換來源",
"VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。"
"VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。",
"NAV_PARENTAL": "Parental lock",
"PARENTAL_LOCK": {
"TITLE": "Parental lock",
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
"ENABLE": "Enable parental lock",
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
"STATUS": "Current state",
"STATUS_LOCKED": "Locked — locked categories are hidden.",
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
"LOCK_NOW": "Lock now",
"UNLOCK": "Unlock",
"RELOCK": "Lock again after",
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
"RELOCK_NEVER": "Only when restarting the app",
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
"CHANGE_PIN": "Change PIN",
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
"HOW_TO_LOCK": "Choosing what to lock",
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
"ENABLED_TOAST": "Parental lock enabled",
"DISABLED_TOAST": "Parental lock disabled",
"PIN_CHANGED_TOAST": "PIN changed",
"LOCKED_TOAST": "Locked"
}
},
"THEMES": {
"DARK_THEME": "深色",
@@ -1681,7 +1706,11 @@
"SWITCH_PLAYER_LABEL": "切換播放器至 {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "將 {{name}} 設為使用中的影片播放器",
"SWITCH_PLAYER_FEEDBACK": "播放器已設為 {{name}}",
"ACCOUNT_INFO_DESCRIPTION_STALKER": "顯示 Stalker 入口網站帳戶資訊"
"ACCOUNT_INFO_DESCRIPTION_STALKER": "顯示 Stalker 入口網站帳戶資訊",
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
},
"XTREAM_REFRESH_TITLE": "正在重新整理播放清單",
"XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒體庫資料...",
@@ -1872,5 +1901,39 @@
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
},
"PARENTAL_LOCK": {
"PIN_DIALOG": {
"UNLOCK_TITLE": "Enter parental PIN",
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
"SET_TITLE": "Set parental PIN",
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
"PIN_LABEL": "PIN",
"CONFIRM_LABEL": "Repeat PIN",
"PIN_HINT": "{{min}} to {{max}} digits",
"WRONG_PIN": "Wrong PIN. Try again.",
"MISMATCH": "The two PINs do not match.",
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"LOCK_GROUP": "Lock group",
"UNLOCK_GROUP": "Unlock group",
"MANAGE_LOCKS": "Lock categories",
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
"LOCK_CENSORED": "Lock adult (18+)",
"UNLOCK_ALL": "Unlock all",
"CENSORED_HINT": "The portal flags this category as adult content",
"SAVE_FAILED": "Saving the category locks failed",
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
"UNLOCK_TO_SHOW": "Enter PIN to show",
"HEADER_LOCK": "Lock now (parental lock)",
"HEADER_UNLOCK": "Enter parental PIN"
}
}
@@ -7,7 +7,7 @@ tags:
- guide
- epg
- m3u
draft: true
draft: false
faq:
- q: Is this guide available in the browser version?
a: The multi-channel M3U Guide described here is a desktop feature in 0.24. The browser version and portal programme views do not offer this same layout.
@@ -38,7 +38,8 @@ covers that preparation.
1. Open an M3U playlist and start a live TV channel.
2. Leave fullscreen if it is active, and close any open settings dialog.
3. Click **Guide** in the programme panel or use the Guide action in the workspace header.
You can also press **G** while on the player page, outside a text field or menu.
The command palette offers the same action. You can also press **G** while on the player
page, outside a text field or menu.
4. Browse the grid. The inline player stays above it alongside information about the
channel you are watching.
+1 -1
View File
@@ -153,7 +153,7 @@ const jsonLd = buildFeaturePageSchema({
</DownloadSection>
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
<RelatedPosts slugs={['m3u-playlist-epg-setup-guide', 'epg-guide', 'xtream-codes-setup-guide']} />
<RelatedPosts slugs={['m3u-programme-guide', 'm3u-playlist-epg-setup-guide', 'epg-guide']} />
<div class="mt-10">
<OfficialSourcesNote />
</div>
+12
View File
@@ -149,6 +149,18 @@ Otherwise the app may still be using an older
`dist/apps/electron-backend/workers/database.worker.js` bundle even though the
TypeScript source has already been updated.
### Parental Lock Toggles
While the parental lock is enabled (`Settings → Parental lock`), the same
dialog renders a lock toggle per row and the whole dialog opens only after the
PIN. Locks are a separate concept from `hidden`: they live in the renderer's
lock store keyed by provider category id and are mirrored into
`categories.locked` through `DB_SET_CATEGORY_LOCKS`, so a refresh keeps them
the same way it keeps `hidden`. The dialog is the one entry point on every
portal type (Stalker's "Manage categories" button opens its lock-only
dialog); a right-click on a category row offers a single-row Lock/Unlock
through the same lock store. Contract: [parental lock](parental-lock.md).
## Files Changed
```
+30 -6
View File
@@ -235,23 +235,47 @@ and value-imports those two dependency-free modules directly.
`@iptvnator/workspace/shell/util/settings-search` exists for the opposite
reason: the main `workspace-shell-util` barrel is imported eagerly, and the
settings search index must stay in the lazy settings and shell chunks.
`@iptvnator/workspace/shell/util/settings-context` keeps
`SettingsContextService`, which only the lazy settings page and settings
context panel use, out of that barrel for the same reason, and
`@iptvnator/services/playlist-backup` keeps `PlaylistBackupService` (about
22 KB, used only by the lazy settings page) out of the `@iptvnator/services`
barrel.
The web app's root shell (`app.component.ts`, `app.config.ts` and the services
they construct) is on the renderer's initial path, where a barrel costs its
whole library: esbuild keeps every Angular component module a barrel
re-exports, because their static definitions count as side effects. Importing
re-exports, because their static definitions count as side effects (so do an
`@Injectable` service's and a pipe's). Importing
`WindowControlsComponent` and `DialogService` from `@iptvnator/ui/components`
once put the channel lists, EPG views, `@angular/forms`, `date-fns` and the
Stalker data layer into `main.js`. The root shell therefore uses file-level
entries: `@iptvnator/ui/components/window-controls`,
`@iptvnator/ui/components/confirm-dialog` and
`@iptvnator/playlist/shared/ui/stalker-connection-editor` (like the existing
`@iptvnator/ui/epg/progress-panel`), and loads anything used only on demand
`@iptvnator/ui/components/confirm-dialog`,
`@iptvnator/playlist/shared/ui/stalker-connection-editor`,
`@iptvnator/playlist/shared/util/playlist-file-import` (the barrel would add
`PlaylistContextFacade`) and `@iptvnator/pipes/date-format` (the barrel would
add `SafePipe`; the eager EPG progress panel uses it too), like the existing
`@iptvnator/ui/epg/progress-panel`, and loads anything used only on demand
through a local file it imports dynamically (the Stalker connection editor,
the release-notes dialog, the external-player info dialog). A local file,
the release-notes dialog, the external-player info dialog, the parental-lock
PIN dialog and the Stalker step of the parental-lock enforcement, which runs
only while a Stalker route is open). A local file,
not the library alias, is the dynamic-import target because
`@nx/enforce-module-boundaries` forbids static imports of a library the same
project also loads dynamically. `renderer.initialBytes` in
project also loads dynamically.
A barrel the initial path imports also costs code it never runs itself:
code splitting places a module in the chunk shared by every entry point
that reaches it, so a helper that only lazy routes use, but that an eager
file can reach through a barrel's re-export, lands in an initial chunk even
though tree shaking would drop it from a single bundle. The eager Xtream
data layer and root shell therefore import `@iptvnator/portal/shared/util/logger`
and `@iptvnator/portal/shared/util/tokens` (the portal DI tokens), not the
`@iptvnator/portal/shared/util` barrel, whose navigation, keyboard-shortcut
and download helpers (about 45 KB) belong to the lazy portal routes. A
type-only import of a barrel uses `import type`, so it can never keep the
barrel reachable. `renderer.initialBytes` in
[performance journeys](performance-journeys.md) guards the result; to see why a
module is eager, build with `pnpm nx build web --stats-json` and follow the
static imports in `dist/apps/web/stats.json` from `apps/web/src/main.ts`.
+560
View File
@@ -0,0 +1,560 @@
# Parental Lock
Issue #285. A PIN-protected lock for individual categories: Xtream
categories, Stalker genres and M3U `group-title`s. While the lock is active,
everything in a locked category is withheld from the app; entering the PIN
shows it again until the app locks itself.
This document is the contract. Phase 1 (this document's scope) covers the
portals' own surfaces; the aggregate surfaces listed under "Not yet covered"
follow in a second PR.
## Product rules
- Off by default. Enabling asks for a new PIN (4–8 digits); disabling and
changing the PIN always verify the current PIN against the stored hash,
even while the session is unlocked (`verifyCurrentPin()`, never the
`requestUnlock()` short-cut) — a parent leaving the app unlocked must not
leave the lock removable. Disabling keeps the locks for a later re-enable.
- The unit of locking is the category. Nothing is blurred or greyed: a
withheld category and its rows are absent. The only trace is one
"N locked · Enter PIN to show" row at the bottom of a portal's category
rail, which opens the PIN prompt. In fail-closed mode (lock store
unreadable) the row shows without a count, since which categories are
locked is unknown while every one of them is withheld.
- The unlock lives in memory only. The app locks again on every restart, on
"Lock now" (header button, command palette, settings), and after
`Settings.parentalLockRelockMinutes` minutes without user interaction
(default 15; `0` = only on restart). The idle timer follows the UNLOCKED
transition: armed the moment a session is unlocked — including the session
that just enabled the feature — and disarmed on lock. Active playback of a built-in web
player counts as interaction, so a film never locks half way — video
through the keep-awake tracker, and audio (the radio player) read from
the playing `<audio>` elements, since the keep-awake service ignores
them.
- This is a child lock, not a security boundary: the PIN hash and the lock
store sit in user-readable app data. The UI says so. There is no PIN
recovery; resetting the app data is the way out.
## Storage
| What | Where (Electron) | Where (PWA) |
| ------------------ | --------------------------------------------------------------------------------- | ------------------------- |
| PIN hash | `app_state` key `parental-lock:pin` | localStorage, same key |
| Lock store | `app_state` key `parental-lock:locks` | localStorage, same key |
| Feature switch | `Settings.parentalLockEnabled`, mirrored to electron-conf `PARENTAL_LOCK_ENABLED` | `Settings` |
| Relock timeout | `Settings.parentalLockRelockMinutes` | same |
| Xtream query index | `categories.locked` column | none (filtered in memory) |
The PIN is hashed with PBKDF2-SHA256 through WebCrypto
(`parental-lock-pin.util.ts`, format `v1$<iterations>$<salt>$<hash>`), so
Electron and PWA share one implementation. The hash is deliberately not part
of `Settings`: settings are logged, backed up and mirrored to the main
process. A hash that could not be READ is not an absent PIN:
`ParentalLockStorageService.readPinHash()` reports the failure as `null`
(an absent PIN is `{hash: null}`), the session then stays locked (`enabled`
treats an unreadable PIN as set while the switch is unknown) and every
PIN-protected step — unlock, change PIN, disable — re-reads it first, so
storage answering later is enough to recover without a restart.
The lock store (`ParentalLockStore` in `parental-lock.util.ts`) is keyed by
playlist id and holds, per playlist, Xtream `{categoryType, xtreamId}`
pairs, Stalker `{categoryType, categoryId}` pairs and M3U group titles. It
is the source of truth for all three portal types. Xtream additionally keeps
`categories.locked` as a SQLite index derived from it: `DB_SET_CATEGORY_LOCKS`
re-stamps one playlist/type from a provider-id list (idempotent, so the store
can be replayed after a refresh or a backup restore), and `DB_SAVE_CATEGORIES`
takes `lockedCategoryXtreamIds` so a refresh recreates the rows already
stamped. Locks are keyed by provider ids, never by SQLite row ids, because a
refresh deletes and re-inserts the rows.
## Enforcement — two layers, both default-closed
1. **SQLite worker (Electron).** `apps/electron-backend/src/app/database/parental-lock-state.ts`
holds one process-wide flag. While active, every content-returning read
appends "category is not locked": `getCategories`, `getContent`,
`searchContent`, the three `globalSearch` candidate selectors,
`getGlobalRecentlyAddedByType`, `DB_MATCH_TITLES` and the multi-source
discovery queries. A surface added later is therefore withheld by
default. The flag is seeded through `workerData.parentalLockActive` when
the worker is (re)started and flipped by the `parental-lock` control
message on the request port (ordered with the requests, so a read posted
after it observes the new state). `getAllCategories` is exempt: it feeds
the management dialog, which itself sits behind the PIN.
2. **Renderer `ParentalLockService`** (`libs/services/src/lib/parental-lock/`).
Signals `enabled`, `unlocked`, `active` (= enabled && !unlocked),
`hasPin`, `version`; `requestUnlock()` (one shared prompt for concurrent
callers), `lock()`, `setupPin()`, `changePin()`, `disable()`, the lock
store API (`setXtreamLocks`, `setStalkerLocks`, `setM3uLocks`,
`replacePlaylistLocks`, `lockedXtreamIds`, …) and the `is*Locked`
predicates, which answer `true` only while `active`. The PIN dialog is
reached through the `PARENTAL_LOCK_PROMPT` token, provided by the app
(`AppParentalLockPromptService` → `ParentalLockPinDialogComponent` in
`libs/ui/components`), so the data-access lib stays free of UI.
Five wrong PINs pause the prompt for 30 seconds. The count and the
pause live in the service (`createParentalLockPinThrottle`, handed to
every unlock prompt), so dismissing the dialog and opening it again
does not reset them.
The renderer reports `active` to the main process over
`PARENTAL_LOCK_SET_STATE` (`apps/electron-backend/src/app/events/parental-lock.events.ts`),
which forwards it to the worker. `SETTINGS_UPDATE` only persists the
`parentalLockEnabled` mirror; it never derives the live state from a settings
save (every save carries the flag unchanged and would re-lock the worker
under a renderer that shows "unlocked"). The one exception is a switch-off,
which releases the worker at once. `requestUnlock()` awaits `initialize()`
before it can answer "not active", so a slow settings load cannot open a
gate. Like the playback keep-awake vote, the
renderer's word does not outlive its page: on reload, main-frame navigation or
a dead render process the flag falls back to the mirrored
`PARENTAL_LOCK_ENABLED` setting, i.e. locked while the feature is on. The
renderer, in turn, reports nothing before its settings have loaded, so it can
never send a spurious "unlocked" while the main process still holds the
locked default. Unreadable settings fail the same way: `SettingsStore`
serves defaults and records `storageFailure = 'load'`, and the switch is
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 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 a stored payload that does not parse or does not have the
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`, and `ElectronXtreamDataSource` serves no
categories, content, cached categories/content (the warm-route hydration
path) 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
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 same fail-closed mode applies while locked when Electron reads Xtream
through the SQLite worker (`supportsXtreamSqliteDataSource`) but the bridge
lacks the worker filter (`supportsParentalLockSqliteFilter`:
`setParentalLockState` and `dbSetCategoryLocks`, e.g. a partial or older
preload): the worker would never learn the lock state, so it cannot
withhold locked rows itself. The same holds while locked after the
lock-state sync itself was rejected (`ParentalLockWorkerSync`), until a
later sync succeeds. The direct worker consumers
(`CatalogTitleMatchService`, `VodSourceDiscoveryService`) ask the worker
nothing while `withholdsEverything` is true, in either case. The VOD
multi-source host keys its discovery session to the lock version: a lock
change drops the discovered sources, retires discoveries and switches in
flight, and rediscovers through the worker's new lock state. Title-match
results cached by the Actor/Discover routes and dashboard services are not
yet retired on relock (tracked in #1723).
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 — and the workspace route's
`settingsReady` resolver awaits `ParentalLockService.initialize()` next to
the settings load, so no route or catalog activates before the PIN and the
lock store are known (in the PWA the settings read can be the slower one).
Every lock write re-reads a failed store BEFORE building its edit, so a
recovered store is edited, never overwritten by an edit built on the empty
fail-closed one. The feature switch itself is
persisted through one guarded path (`persistEnabled`), and every
parental-lock settings write first retries a failed startup settings read
(`ensureSettingsReadable`) and is refused while settings stay unreadable —
`updateSettings` writes the whole settings object, which after a failed
read is the defaults and would replace the user's persisted preferences.
Within that path `updateSettings`
patches memory before it writes, so a failed write is undone in memory and
`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. The undo restores the value read AFTER the settings retry
(not the hard-coded inverse, which a recovered read may already hold). The
Settings switch itself only requests the change: it snaps back to the
saved state at once and follows `enabled()` when the PIN action succeeds,
so a cancelled or refused PIN leaves it showing the real state.
### In-memory catalogs
- **Xtream (Electron):** the store reads categories and streams through the
worker, so they arrive filtered. On `version` changes
`ParentalLockEnforcementService` (`apps/web/src/app/services/`) calls
`XtreamStore.reloadCategories()` + `reloadCachedContent()` and, if the
selected category vanished, clears the selection and navigates to the
section root. The selected ITEM is judged separately, on its own
`category_id` against the reloaded category list: a detail opened from
"All", recently added or search has no selected category to vanish
with, so its row disappearing from a list is not enough — the item is
cleared and the route returns to the section root while the (still
readable) selected category stays. Within one apply the synchronous
surfaces (the M3U active channel, the Stalker selection) are stepped off
BEFORE the Xtream reloads are awaited, so a playing M3U channel never
waits behind a slow portal read — the Xtream store stays populated after
leaving that portal, so its reload runs on every apply. The M3U check
also runs whenever the active channel changes while locked: numeric
zapping, next/previous and remote commands select from the full channel
list without any lock change following, so a channel of a locked group
is reset as soon as it becomes active (numeric zapping also skips it). Applies run one
at a time and each is
abandoned once a newer `version` exists (the queued apply reads the latest
state): `ElectronXtreamDataSource` shares in-flight category/content
reads per playlist and type, and its share key carries the lock version,
so an unlock refresh still running when "Lock now" arrives can never hand
the relock its unfiltered rows. The apply also re-runs the stored
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. A RELOCK fails closed at once rather than after the
database answers — ahead of the serialized apply queue, so an earlier
apply still waiting on a slow or hung read cannot delay it: the selected detail is stepped off synchronously when
the lock store already names its category (the pre-reload category list
maps Electron's row id to the provider id) or when that list cannot place
it at all (a manually hidden category opened through search), then
`withholdCatalog()`
empties every catalog list and `clearSearchResults()` the stored search
(retiring a search still in flight, which was issued under the previous
lock state) before the filtered reads refill them; both reloads take a publish guard
answered before every state patch, so a read issued under an older lock
version is dropped instead of published. The guard also turns false once
another Xtream playlist is open (the store is a singleton), and the store
itself refuses to publish a reload into a playlist it was not read for;
the post-reload search refresh and selection checks are skipped then
too. The post-reload checks of the
selected category and item decide by the LOCK STORE through the
unfiltered category rows (`IXtreamDataSource.getAllCategories`), not by
absence from the reloaded list, which also omits categories the user
merely hid; rows that cannot be read fail closed. A lock change that overtakes
the INITIAL hydration (the content is not initialized yet, so the reload
has nothing to re-read) sets a deferred-reload flag instead — already
when `withholdCatalog()` empties the lists, before the category reload is
awaited: the
hydration then publishes empty lists in place of the rows it read under
the previous lock state (categories included), and the filtered reload
of categories and content runs as soon as the hydration settles, on
every path that marks the content initialized, under the publish guard
of the request that deferred it.
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 — through the visible
category list, else through the unfiltered rows, since search can play a
channel of a HIDDEN category; until that lookup lands the id is unknown
and a relock stops the channel, and a lookup that lands after a later
playback — same provider id, other playlist — is discarded) and drops `activePlayback` on a `version`
change that locks it, because the player is gated on that
signal, not on the store selection — which an ordinary category switch
also clears while the channel keeps playing.
- **Stalker:** the same service checks the selected genre through
`isStalkerCategoryLocked` and, independently, the selected item's own
genre — `tv_genre_id` for live and radio rows, `category_id` for VOD and
series, the rule the store's withheld filter applies — so an item opened
from `*` (All) or search is withheld by its genre even though `*` itself
can never be locked. A withheld item is cleared and the section root is
navigated to; the category selection is reset only when the genre itself
is locked. A stream still RESOLVING at relock time is retired too: the
embedded player defers selecting the channel until resolution, so the
cleared selection cannot retire it — the playback request carries the
lock version it was issued under (`isStalkerPlaybackRequestLockCurrent`)
and is dropped when the session relocked meanwhile (an unlock lets it
complete). `StalkerLiveStreamLayoutComponent` drops its `activePlayback`
whenever the store selection is cleared: its template mounts the player
only with a selection, and the held stream must not resurface with the
next one.
- **M3U groups rail:** `ChannelListContainerComponent` derives
`withheldGroupCount` (groups of the playlist the active lock withholds)
and the groups rail renders the same "N locked · Enter PIN to show" row
as the portal category rail, so locked groups do not simply vanish —
also when EVERY group is locked: the container keeps the groups view
(`showChannelViews`) and the groups view keeps its rail instead of the
generic empty state.
- **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories,
streams and search hits at read time; the same reloads apply. Provider
category ids are compared in canonical numeric form (`"009"` matches the
stored lock `9`), as the lock editor stores them with `Number`.
- **Warm-cache detection (Electron):** the filtered category/content reads
can be empty while the offline cache is complete (every category locked),
so `ElectronXtreamDataSource` confirms an empty read with the unfiltered
`hasXtreamCategories` / `hasXtreamContent` before refetching from the
provider.
- **Routes:** `parentalLockXtreamCategoryGuard(section)` on every Xtream
`:categoryId` route (live, vod, series and their detail children) and
`parentalLockStalkerCategoryGuard(section)` on the Stalker vod/series
`:categoryId` routes prompt for the PIN when a locked category is reached
by URL — bookmark, typed address, stale link — and redirect to the section
root on refusal. Detail routes also resolve the ITEM's own category through
`getContentByXtreamId`, so a locked movie paired with an unlocked category
id in the URL is still refused; on a cold PWA navigation the guard
hydrates the session cache first (`getPlaylist` + `getContent`), and an
item the catalog cannot place fails closed (PIN prompt) rather than
passing as unlocked. Electron routes carry SQLite row ids, so
the Xtream guard maps them through the unfiltered category read; Stalker
routes already carry the genre id.
- **Stalker withheld-row bookkeeping:** the paging logic that advances past
a page made only of withheld rows keys those rows by
`stalkerWithheldRowKey` (`id`, else `stream_id`/`movie_id`/`series_id`,
else the row's `cmd`/`name`), never by a shared `''`, so a page of new
locked rows is not mistaken for a stalled portal.
- **Stalker search relock:** a lock change drops the withheld rows already
on screen at once (`applyRelockToResults`, page 1 included) and closes an
open detail of a now-withheld genre (in fail-closed mode also one
without a genre), before the replacement page is awaited — otherwise
they stay clickable while that request is pending.
- **Stalker search staleness:** portal requests are not aborted, so a
search page issued before a relock can finish after it, filtered with the
pre-relock withheld set; `isStalkerSearchRequestCurrent` keys the
response on the parental lock version as well as term/type/page/portal,
so such a page is dropped instead of repopulating the grid.
- **Stalker search route** (`/workspace/stalker/:id/search`, no category
in the URL): `StalkerSearchComponent` filters each portal page through the
same withheld-genre predicate, carries `parentalLockVersion` in its
resource params, judges paging progress on the raw page (a page made only
of locked rows is not the end of the results), advances past a run of
fully withheld pages by itself (the infinite scroll stops auto-filling
after a few no-growth loads), closes an open detail whose genre is newly
withheld and, on a lock flip past page 1, drops the withheld rows and
restarts from page 1.
- **Stalker:** genres are stored unfiltered; `getCategoryResource` filters
them, `getAllCategoriesForSelectedType` is the raw list for the lock
dialog. `itvFullChannelList` and the content loader drop rows whose
`tv_genre_id` / `category_id` is withheld, the content resource's params
carry `parentalLockVersion` so lock/unlock re-fires it, and a stale
response is discarded when the version moved. Paging is judged on the RAW
page: withheld ids the list has not seen before count as progress (so a
portal page made only of locked rows does not end the list), a page that
is entirely withheld requests the next page by itself, and the VOD/series
`totalCount` is reduced by the withheld ids seen so the grid stops asking
once every visible row is in. A lock flip while the list sits past page 1
drops the withheld rows on screen at once and restarts from page 1, so rows
accumulated under the old lock state are never appended to. A selected
withheld genre is cleared and the
section root is navigated to.
- **M3U:** `ChannelListContainerComponent` derives one `visibleChannelList`
(all views, favorites, recents, the fullscreen panel and numeric zapping
read from it); locked groups are absent from the groups rail. A playing
channel of a locked group is reset on lock.
## UI
- `Settings → Parental lock` (`/workspace/settings/parental`): enable
toggle, state + Lock now / Unlock, relock select, Change PIN, the
disclaimer. Everything applies immediately (it gates on the PIN), outside
the shared dirty form; `createSettingsFromFormValue` carries both values
from the current settings so Save cannot undo them.
- One entry point per rail, the same on every portal type: the "Manage
categories" button (`tune` icon; "Manage groups" on an M3U playlist).
Xtream and M3U render lock toggles inside their existing hide/show
dialogs while the feature is on; Stalker has no hide/show dialog, so its
button — shown only while the feature is on — opens the lock-only
`StalkerCategoryLockDialogComponent`, which also offers "Lock adult
(18+)" for genres the portal flags `censored`. All three list the locked
names and can rewrite the locks, so each opens only after
`requestUnlock()` succeeds — and closes itself, discarding the draft, the
moment `active` becomes true again (idle relock, Lock now), since the
PIN gate covers only the opening. The M3U group dialog does this only
while it carries lock toggles; the plain hide/show editor is not behind
the PIN. The Xtream dialog loads 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.
A deliberate non-choice: no dedicated lock button in the rail header
(the header already carries search, sort and manage, and the lock is a
category-management concern) and no keyword-based "adult" recognition
for Xtream/M3U (provider naming is arbitrary; the only automation is the
portal's own `censored` flag on Stalker).
- Right-click accelerator: a `contextmenu` on a category row (Xtream and
Stalker rail, `WorkspaceContextCategoryViewComponent`) or an M3U group
row opens the shared `CategoryLockMenuComponent` (`libs/ui/components`)
with one "Lock / Unlock category (group)" item, only while the feature
is on. The portal rail persists through
`WorkspaceCategoryLockActionService` (PIN gate, then one-id edit of the
lock store, failure snackbar); the M3U rail emits the toggled group to
`ChannelListContainerComponent`, which captures the playlist, asks for
the PIN, and persists the edit only if that playlist is still open (two
playlists can share a group name). The M3U management dialog is bound
the same way: `GroupsViewComponent` captures its `playlistId` input
before the PIN and drops the dialog's result once another playlist is
open. Every bulk lock editor (Xtream and M3U management dialogs, the
Stalker lock dialog) builds its draft only from a lock store that was
read (`ensureLocksReadable`): a draft taken from the empty fail-closed
snapshot would erase the real locks on Save if storage recovered in
between. The M3U and Xtream dialogs then open without lock toggles; the
Stalker dialog does not open. Every one of them closes on a relock, the
M3U one also when it opened without toggles, since it still lists every
group name. Bulk actions stay in the dialog, and while the session is locked
a locked category is not in the rail, so unlocking always goes through
the "N locked · Enter PIN to show" row or the dialog. 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. The relock timeout persists through the
same undo-on-failure pattern as the switch (`setRelockMinutes` reverts
the in-memory value and the facade shows the settings save-failure
snackbar). A locked session cannot change it: the selector is disabled
until the PIN is entered, and `setRelockMinutes` refuses while `active`,
so a child cannot switch the idle relock off for the next unlock.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
## Startup footprint
The lock service, lock store and enforcement are on the renderer's initial
path by necessity (the workspace resolver and the catalog data sources gate
on them). Everything else stays lazy: the PIN dialog loads through
`parental-lock-pin-dialog.lazy.ts` on the first prompt (a failed chunk load
reads as a cancelled prompt), and the enforcement's Stalker step loads
through `parental-lock-stalker-enforcement.ts` only while a Stalker route is
open — a static import of the Stalker store would put the whole Stalker
data layer back into `main.js`. The chunk is preloaded when a Stalker route
opens, so a relock there runs the step synchronously; if it is not loaded
yet (still fetching, or it cannot load — a stale PWA page after a
deployment) the step fails closed at once by navigating to
`/workspace/sources`: leaving the Stalker route clears its selection and
stops its playback, and the Xtream step still runs. The feature costs about 35 KB of
`renderer.initialBytes`; the baseline was not raised for it, the growth was
offset by moving lazy-only barrel re-exports (backup/restore and the portal
helpers) off the initial path.
## Lock store lifetime
On Electron the SQLite `categories.locked` index is derived from the lock
store and must never lag behind it, because the worker filters every read
by the index alone:
- The store commits BEFORE the re-stamp, so a failed re-stamp rolls the
store back to the previous locks AND re-stamps every touched type from
them (a backup restore stamps three types, and the ones before the
failing type already carry the new locks). The store revision consumers
reload on is published only once every touched type is stamped — also
after a rollback — so a reload cannot read a later type through its old
stamps. If the rollback write or its re-stamp fails too, the playlist is
marked stale and re-stamped on the next store access. A failed rollback
WRITE still puts memory back to the previous locks and leaves a pending
rewrite: the next store access persists them before it re-stamps, so the
failed edit cannot take effect through that re-stamp. The pending rewrite
lives in memory: if the app exits before it lands, the next launch loads
the persisted copy, i.e. the edit the parent submitted with the PIN. That
is accepted — it is not a bypass, and a durable rollback journal would
hit the same storage failure. For the whole
re-stamp window — store changed, stamps not yet landed — the playlist
counts as stale, so `readable` is false and a relock inside the window
reloads fail-closed instead of through the old stamps. Such in-flight
entries are not retried by a reconcile running beside the write (a PIN
prompt or backup calling `ensureReadable()`): it would stamp from a store
the write has not committed yet. Only failed stamps are retryable.
- A write that removes a playlist's LAST lock clears the index first and
drops the key afterwards: the launch-time reconcile finds playlists only
through their key, so an interruption must leave store-with-lock and
index-without, which the next reconcile repairs toward locked. If the
clear or the store write fails, the index is re-stamped from the previous
locks at once — title matching and multi-source discovery query the
worker directly and trust the index, so a stale flag alone would not
protect them. The playlist stays stale until the store write has landed
too: while it is pending the index is already cleared but the store
still holds the lock.
- An edit that takes a lock away commits only while the session is
unlocked, checked inside the write queue at commit time
(`ParentalLockLockStore.setRemovalGate`, set by `ParentalLockService`).
An editor opened while unlocked may still be saving, or queued behind
another write, when the app relocks. The gate is asked right before the
edit's first write is issued; a relock that lands after that is ordered
after the write, which completes: the parent authorized that removal,
and a post-write rollback could itself fail and leave the persisted
store diverged from memory across a restart. (During the Xtream stamps
the playlist is stale anyway, so the relock reloads fail-closed until
they land.) Adding locks is always allowed.
Playlist deletion and "Remove all playlists" are not edits and are not
gated.
- Every launch re-derives the index from the store for each playlist that
has locks, and a store recovered by a later successful read marks its
playlists stale the same way. The reconcile is 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.
Every lock-store mutation runs through one write queue in
`ParentalLockLockStore`: each rewrites the whole persisted store from the
in-memory copy, so overlapping edits (two right-click toggles, a dialog
save during a restore) would otherwise snapshot the same store and the
later write would drop the earlier edit. Single-row changes (the
right-click Lock/Unlock) are passed as an EDIT of the current list
(`LockListEdit`), evaluated inside that queue, so two quick toggles each
see the other's result instead of one shared snapshot; the dialogs pass a
full list, which is the replacement they mean. A deleted playlist's locks leave
the store through the `PLAYLIST_DELETE_CLEANUP` hook
(`provideParentalLockPlaylistCleanup`, run by
`PlaylistsService.deletePlaylist` for every single-playlist deletion; no
re-stamp, the category rows go with the playlist), and "Remove all
playlists" empties it through `ParentalLockService.clearAllLocks` once the
deletion has succeeded — the in-memory store empties at once, and a failed
persisted clear is retried on the next store access (the store is not
`readable` until it lands). A backup restore that CREATES a playlist (not a merge) starts
that playlist from empty locks, so a reused id can never inherit entries a
failed cleanup left behind; the restore retries a failed lock-store read
first (`ensureLocksReadable`) and aborts while it still fails, so that check
never runs against the empty fail-closed snapshot.
## Backup
A backup carrying lock lists replaces the matching playlists' locks,
possibly with an emptier set, so the import asks for the PIN first (after
the file was chosen) and aborts when it is refused. That answer can go
stale during a long import (idle relock, "Lock now"), so an entry whose
restore would take a lock away asks again right before it replaces locks
if the app has relocked; a refusal fails that entry and keeps its previous
locks. A restore that only adds locks is not asked again. Locks are
restored LAST
for each entry, after the Xtream data restore, so a
failed merge leaves the playlist's previous locks in place. M3U group titles
travel verbatim (`normalizeParentalLockGroupTitles`, exact
dedup): locks match `channel.group.title` exactly, so the trimming
`uniqueStrings` used for favorites would weaken a lock on a title with
surrounding whitespace. A backup's lock lists are validated entry by entry
on import (`isWellFormedParentalLock*` in the shared contract): restore
treats them as authoritative and the normalizer drops what it does not
understand, so a damaged list is rejected rather than erasing the
playlist's persisted protection.
`lockedGroupTitles` (M3U), `lockedCategories` (Xtream `{categoryType,
xtreamId}`, Stalker `{categoryType, categoryId}`) travel in each entry's
`userState`, written only when the playlist has locks. Absent means "no
opinion" and restore leaves the target's locks alone; present replaces them
(`replacePlaylistLocks`, which also re-stamps the Xtream index). The PIN is
never exported.
## Not yet covered (phase 2)
Favorites, recently viewed, playback positions / Continue Watching, the
dashboard rails built from Stalker and M3U documents, global favorites /
recent pages, the EPG guide, downloads and recordings lists, the remote
control's channel-number resolvers, the command palette's channel search and
the Electron-only global search page (`/workspace/search`, whose stored
results are not re-run on a relock) still show rows from locked categories. The SQLite-side hooks exist
(`unlockedCategoryCondition()` / `unlockedCategorySql()`), the renderer
predicate is `ParentalLockService.is*Locked`.
## Files
- 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/` (`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-section.component.*` and `settings-parental-lock.facade.ts` (rows anchored and indexed for settings search),
`libs/ui/components/src/lib/parental-lock-pin-dialog/`,
`libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/`.
- Electron: `apps/electron-backend/src/app/database/parental-lock-state.ts`,
`services/parental-lock-state.ts`, `events/parental-lock.events.ts`,
`database/operations/category.operations.ts` (`setCategoryLocks`).
+11
View File
@@ -273,6 +273,17 @@ the first measurements was otherwise `package.json` text embedded in
`main.js`, which moved with every script edit; #1692 fixed that by importing
only the version.)
Two effects make the exact counter move for reasons outside a PR's own diff.
A baseline lowered on a branch that predates a concurrent `master` merge can
sit below what the merged code measures: #1712 lowered it on a branch without
#1714, so `master` measured 108 bytes over and every later PR failed the job
until a follow-up moved lazy-only modules out of `main.js`. Re-run the job on
an up-to-date branch before merging a baseline change. And the bundler's
chunk-level identifier renaming shifts when a module enters or leaves
`main.js`: moving one service out once renamed an imported identifier at 162
call sites, eating about 320 of the bytes saved. Judge a small change by the
`--stats-json` input sizes, not only by the counter.
The job also refuses a weakened baselines file:
`tools/performance/check-baseline-direction.mjs` compares
`journey-baselines.json` with the revision the change is measured against
+4 -1
View File
@@ -52,7 +52,10 @@ Current workspace routes:
8. `/workspace/search`
9. `/workspace/downloads`
10. `/workspace/settings/:section` (`/workspace/settings` redirects to
`general`; the settings context panel links each section page)
`general`; the settings context panel links each section page; sections:
`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`,
`parental` — see [parental lock](parental-lock.md) — `backup`, `reset`,
`about`)
11. `/workspace/xtreams/:id/...`
12. `/workspace/stalker/:id/...`
+1
View File
@@ -39,6 +39,7 @@ are not prerequisites for reading repository contracts.
| Live panels, keyboard focus, grid/layout conventions; shared UI and portal views | [UI guidelines](../architecture/iptvnator-ui-guidelines.md), [detail navigation](../architecture/portal-detail-navigation.md) | [UI design](../../.codex/skills/iptvnator-ui-design/SKILL.md), [theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) |
| Workspace routes, title bar, switcher, collections and dashboard; `libs/workspace` | [Workspace shell](../architecture/workspace-shell.md), [dashboard](../architecture/workspace-dashboard.md), [collection/detail navigation](../architecture/portal-detail-navigation.md) | UI/theme skills for visible changes |
| Remote control, playback queue, channel return and shortcuts; `libs/ui/remote-control`, `apps/remote-control-web` | [Remote control](../architecture/remote-control.md) | Provider skill when queue ownership changes |
| Parental lock: PIN, per-category locks, worker-side filtering; `libs/services/src/lib/parental-lock`, category/group dialogs, `apps/electron-backend/src/app/database/parental-lock-state.ts` | [Parental lock](../architecture/parental-lock.md), affected provider contract | Read the affected provider skill |
| Downloads, offline details, catch-up and file availability; `libs/portal/downloads` | [Download manager](../architecture/download-manager.md), provider contract for URL resolution | Read the affected provider skill |
| VOD source discovery, factual metadata and failover; `libs/portal/shared/data-access` | [VOD multi-source](../architecture/vod-multi-source.md) | [Xtream](../../.codex/skills/xtream-electron/SKILL.md) |
| TMDB enrichment, artwork, actors and recommendations; `libs/services/src/lib/tmdb` | [TMDB contracts](../architecture/tmdb-metadata-enrichment.md), [dashboard](../architecture/workspace-dashboard.md) | UI skill for rendering changes |
@@ -114,6 +114,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
import {
DataService,
ParentalLockService,
PlaylistsService,
RecordingsService,
RuntimeCapabilitiesService,
@@ -241,6 +242,7 @@ export class VideoPlayerComponent
private readonly router = inject(Router);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly parentalLock = inject(ParentalLockService);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly epgService = inject(EpgService);
@@ -1437,8 +1439,19 @@ export class VideoPlayerComponent
)
)
.subscribe((channel) => {
// The number indexes the full list; a channel of a locked
// group is not zapped to (the enforcement service would
// reset it anyway, after playback had started).
const playlistId = this.activePlaylistMeta()?._id;
if (
channel &&
!(
playlistId &&
this.parentalLock.isM3uGroupLocked(
playlistId,
channel.group?.title ?? ''
)
) &&
(!this.isLivePlayerFullscreen() ||
this.keepsInlinePlayer(channel))
) {
@@ -1,5 +1,23 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ParentalLockService } from '@iptvnator/services';
import { VodSourceDiscoveryService } from './vod-source-discovery.service';
const withholdsEverything = signal(false);
function createService(): VodSourceDiscoveryService {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{
provide: ParentalLockService,
useValue: { withholdsEverything },
},
],
});
return TestBed.inject(VodSourceDiscoveryService);
}
/**
* Discovery talks to a foreign playlist, and Xtream carries the account in the
* URL — so anything it fails on has to go through the redacting logger before
@@ -17,7 +35,7 @@ describe('VodSourceDiscoveryService — failure logging', () => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {
/* captured */
});
service = new VodSourceDiscoveryService();
service = createService();
});
afterEach(() => {
@@ -85,7 +103,7 @@ describe('VodSourceDiscoveryService — candidate mapping', () => {
row(4, undefined),
]),
};
const service = new VodSourceDiscoveryService();
const service = createService();
const result = await service.discover({
title: 'Dune',
@@ -111,3 +129,24 @@ describe('VodSourceDiscoveryService — candidate mapping', () => {
};
}
});
describe('VodSourceDiscoveryService — parental lock', () => {
afterEach(() => {
withholdsEverything.set(false);
delete (window as { electron?: unknown }).electron;
});
it('asks the worker nothing while the lock withholds everything', async () => {
const dbFindTitleSources = jest.fn().mockResolvedValue([]);
(window as { electron?: unknown }).electron = { dbFindTitleSources };
withholdsEverything.set(true);
const result = await createService().discover({
title: 'Dune',
currentPlaylistId: 'playlist-0',
});
expect(result.sources).toEqual([]);
expect(dbFindTitleSources).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { ParentalLockService } from '@iptvnator/services';
import {
unambiguousCategoryLanguage,
type VodSourceCandidate,
@@ -43,6 +44,7 @@ export interface VodSourceDiscoveryResult {
@Injectable({ providedIn: 'root' })
export class VodSourceDiscoveryService {
private readonly logger = createLogger('VodSourceDiscovery');
private readonly parentalLock = inject(ParentalLockService);
get isAvailable(): boolean {
return (
@@ -59,7 +61,14 @@ export class VodSourceDiscoveryService {
matchKind: 'title-year',
};
if (!this.isAvailable || !request.title?.trim()) {
// The worker withholds locked rows through its own lock state and
// index; while the lock withholds everything (store unreadable, or
// a bridge without the worker filter) neither can be trusted.
if (
!this.isAvailable ||
!request.title?.trim() ||
this.parentalLock.withholdsEverything()
) {
return empty;
}
+7
View File
@@ -0,0 +1,7 @@
// Dependency-free entry for the DI tokens the web app's root shell and the
// eager Xtream data layer provide or inject. The main barrel would put every
// portal helper that lazy routes use onto the renderer's initial path.
export * from './lib/portal-external-playback';
export * from './lib/portal-playback-positions';
export * from './lib/portal-player';
export * from './lib/portal-shell-actions';
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { DataService, ParentalLockService } from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
PlaylistMeta,
@@ -138,16 +138,27 @@ describe('withStalkerContent failure states', () => {
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let parentalLock: {
active: jest.Mock<boolean, []>;
version: ReturnType<typeof signal<number>>;
lockedStalkerIds: jest.Mock<string[], [string, string]>;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
parentalLock = {
active: jest.fn(() => false),
version: signal(0),
lockedStalkerIds: jest.fn(() => []),
};
TestBed.configureTestingModule({
providers: [
TestContentStore,
{ provide: DataService, useValue: dataService },
{ provide: ParentalLockService, useValue: parentalLock },
{
provide: StalkerItvCacheService,
useValue: createItvCacheMock(),
@@ -590,6 +601,149 @@ describe('withStalkerContent failure states', () => {
expect(store.totalCount()).toBe(2);
});
it('pages past a VOD page made only of parental-locked rows', async () => {
parentalLock.active.mockReturnValue(true);
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
dataService.sendIpcEvent.mockImplementation(
(_event: unknown, payload: { params?: { p?: number } }) => {
const page = Number(payload.params?.p ?? 1);
// Page 1: one visible film. Page 2: locked rows only. Page 3:
// the visible film paging must still reach.
const data =
page === 1
? [{ id: 'movie-1', name: 'One', category_id: '5' }]
: page === 2
? [
{ id: 'adult-1', name: 'A', category_id: '9' },
{ id: 'adult-2', name: 'B', category_id: '9' },
]
: [
{
id: 'movie-3',
name: 'Three',
category_id: '5',
},
];
return Promise.resolve({ js: { data, total_items: 4 } });
}
);
store.setSelectedContentType('vod');
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
{ category_id: '9', category_name: 'Adult' },
]);
store.setSelectedCategory('*');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 1);
expect(store.hasMoreContent()).toBe(true);
// The append lands on the fully withheld page; the loader must ask
// for the next one by itself instead of ending the list.
store.setPage(1);
await waitForCondition(
() => store.getPaginatedContent().length === 2,
60
);
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
'movie-1',
'movie-3',
]);
// Both withheld ids are subtracted from the portal's total.
expect(store.totalCount()).toBe(2);
expect(store.hasMoreContent()).toBe(false);
});
it('drops accumulated rows of a newly locked genre and restarts from page 1', async () => {
dataService.sendIpcEvent.mockImplementation(
(_event: unknown, payload: { params?: { p?: number } }) => {
const page = Number(payload.params?.p ?? 1);
const data =
page === 1
? [
{ id: 'movie-1', name: 'One', category_id: '5' },
{ id: 'adult-1', name: 'A', category_id: '9' },
]
: [{ id: 'movie-2', name: 'Two', category_id: '5' }];
return Promise.resolve({ js: { data, total_items: 3 } });
}
);
store.setSelectedContentType('vod');
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
{ category_id: '9', category_name: 'Adult' },
]);
store.setSelectedCategory('*');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 2);
store.setPage(1);
await waitForCondition(() => store.getPaginatedContent().length === 3);
// Lock now: the genre-9 row loaded on page 1 must leave the screen
// and the list must be rebuilt from page 1 under the new lock state.
parentalLock.active.mockReturnValue(true);
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
parentalLock.version.set(1);
await waitForCondition(
() =>
store.page() === 0 &&
!store.isPaginatedContentLoading() &&
store
.getPaginatedContent()
.every((item) => item.category_id !== '9'),
60
);
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
'movie-1',
]);
expect(store.totalCount()).toBe(2);
});
it('takes page-one rows of a newly locked genre off screen before the reload answers', async () => {
let hangReload = false;
dataService.sendIpcEvent.mockImplementation(() =>
hangReload
? new Promise(() => undefined)
: Promise.resolve({
js: {
data: [
{ id: 'movie-1', name: 'One', category_id: '5' },
{ id: 'adult-1', name: 'A', category_id: '9' },
],
total_items: 2,
},
})
);
store.setSelectedContentType('vod');
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
{ category_id: '9', category_name: 'Adult' },
]);
store.setSelectedCategory('*');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 2);
// Lock now while the replacement request hangs.
hangReload = true;
parentalLock.active.mockReturnValue(true);
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
parentalLock.version.set(1);
// Page 1 blanks the grid synchronously before its request, so the
// locked row is gone even though the reload never answers.
await waitForCondition(() => store.getPaginatedContent().length === 0);
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
});
it('keeps accumulated pages when an append fails and retries the same page', async () => {
let failPageTwo = true;
dataService.sendIpcEvent.mockImplementation(
@@ -9,7 +9,13 @@ import {
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService, resetHostConnectivityGuard } from '@iptvnator/services';
import {
DataService,
ParentalLockService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
import {
StalkerCategoryItem,
StalkerContentItem,
@@ -31,8 +37,29 @@ import {
filterItvChannelsByGenre,
toStalkerContentItem,
toStalkerItvChannel,
stalkerWithheldRowKey,
withoutWithheldStalkerItems,
} from '../utils';
/**
* Genre ids of the given section that the parental lock currently withholds
* for this portal; empty while unlocked or off.
*/
function withheldStalkerCategoryIds(
parentalLock: ParentalLockService,
playlist: PlaylistMeta | undefined,
contentType: StalkerContentType
): 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));
}
/**
* Content/categories/channels feature state.
*/
@@ -250,7 +277,8 @@ export function withStalkerContent() {
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService),
translateService = inject(TranslateService),
itvCache = inject(StalkerItvCacheService)
itvCache = inject(StalkerItvCacheService),
parentalLock = inject(ParentalLockService)
) => {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
@@ -261,6 +289,13 @@ export function withStalkerContent() {
};
let lastLivePageKey = '';
// Withheld (parental-locked) row ids seen while accumulating
// the current list. A page that adds only withheld ids still
// counts as progress, so paging continues past it; a page
// adding nothing new — withheld or not — is a stalled portal.
let withheldSeenKey = '';
const withheldSeenIds = new Set<string>();
let lastParentalLockVersion: number | null = null;
return {
categoryResource: resource({
params: () => ({
@@ -412,6 +447,10 @@ export function withStalkerContent() {
(category) =>
String(category.category_id) !== '*'
).length,
// Lock/unlock re-fires the loader: rows of a
// locked genre are dropped at patch time, so the
// list must be rebuilt when they become visible.
parentalLockVersion: parentalLock.version(),
}),
loader: async ({
params,
@@ -457,15 +496,74 @@ export function withStalkerContent() {
}
const categoryParam = params.category || '*';
const withheldCategoryIds =
withheldStalkerCategoryIds(
parentalLock,
playlist,
params.contentType
);
// A lock flip while the list is past page 1 must
// not append filtered rows onto pages that were
// accumulated under the old lock state: drop the
// withheld rows on screen now and restart from
// page 1 so the list is rebuilt under the new one.
if (
lastParentalLockVersion !== null &&
params.parentalLockVersion !==
lastParentalLockVersion &&
params.pageIndex > 1
) {
lastParentalLockVersion =
params.parentalLockVersion;
const retained = withoutWithheldStalkerItems(
store.paginatedContent(),
params.contentType,
withheldCategoryIds
);
patchState(store, {
paginatedContent: retained,
...(params.contentType === 'itv'
? {
itvChannels:
withoutWithheldStalkerItems(
store.itvChannels(),
'itv',
withheldCategoryIds
),
}
: params.contentType === 'radio'
? {
radioChannels:
withoutWithheldStalkerItems(
store.radioChannels(),
'radio',
withheldCategoryIds
),
}
: {}),
});
(
store as unknown as {
setPage?: (page: number) => void;
}
).setPage?.(0);
return retained;
}
lastParentalLockVersion =
params.parentalLockVersion;
if (params.contentType === 'itv') {
const cachedChannels =
itvCache.getChannels(playlist);
const channels =
cachedChannels !== null
? filterItvChannelsByGenre(
cachedChannels,
categoryParam
? withoutWithheldStalkerItems(
filterItvChannelsByGenre(
cachedChannels,
categoryParam
),
params.contentType,
withheldCategoryIds
)
: null;
// Serve from the cache only when it actually
@@ -522,6 +620,8 @@ export function withStalkerContent() {
params.pageIndex ===
storeContext.page() + 1 &&
paramsPlaylistKey === currentPlaylistKey &&
params.parentalLockVersion ===
parentalLock.version() &&
// A legacy paged response must not overwrite
// the full cached list that a re-fired
// loader served in the meantime. Scoped
@@ -609,12 +709,70 @@ export function withStalkerContent() {
return [];
}
const newItems = response.js.data.map((item) =>
const rawItems = response.js.data.map((item) =>
toStalkerContentItem(
item,
playlist.portalUrl ?? ''
)
);
const newItems = withoutWithheldStalkerItems(
rawItems,
params.contentType,
withheldCategoryIds
);
const listKey = JSON.stringify([
paramsPlaylistKey,
params.contentType,
params.category,
params.search,
]);
if (
params.pageIndex === 1 ||
withheldSeenKey !== listKey
) {
withheldSeenKey = listKey;
withheldSeenIds.clear();
}
let newWithheldCount = 0;
if (newItems.length < rawItems.length) {
const kept = new Set(newItems);
for (const item of rawItems) {
if (kept.has(item)) {
continue;
}
const id = stalkerWithheldRowKey(item);
if (!withheldSeenIds.has(id)) {
withheldSeenIds.add(id);
newWithheldCount += 1;
}
}
}
// A page made only of withheld rows would
// leave the list unchanged; request the next
// one so unlocked rows further on still load.
const skipWithheldPage = (hasMore: boolean) => {
if (
!hasMore ||
newItems.length > 0 ||
newWithheldCount === 0
) {
return;
}
queueMicrotask(() => {
if (isCurrentRequest()) {
// `page` belongs to the selection
// feature; the facade composes
// its setter ahead of this one.
(
store as unknown as {
setPage?: (
page: number
) => void;
}
).setPage?.(params.pageIndex);
}
});
};
if (
params.contentType === 'itv' ||
@@ -645,6 +803,16 @@ export function withStalkerContent() {
const replay =
livePageKey === lastLivePageKey;
lastLivePageKey = livePageKey;
const hasMoreChannels =
rawItems.length > 0 &&
(params.pageIndex === 1 ||
replay ||
newWithheldCount > 0 ||
nextChannels.length >
existingChannels.length) &&
nextChannels.length +
withheldSeenIds.size <
(response.js.total_items ?? 0);
patchState(store, {
totalCount:
response.js.total_items ?? 0,
@@ -662,15 +830,9 @@ export function withStalkerContent() {
},
}
: { radioChannels: nextChannels }),
hasMoreChannels:
channels.length > 0 &&
(params.pageIndex === 1 ||
replay ||
nextChannels.length >
existingChannels.length) &&
nextChannels.length <
(response.js.total_items ?? 0),
hasMoreChannels,
});
skipWithheldPage(hasMoreChannels);
} else {
// VOD/series pages accumulate into one
// continuous list for the infinite-scroll
@@ -694,18 +856,30 @@ export function withStalkerContent() {
// end on every scroll crossing.
const appendStalled =
params.pageIndex > 1 &&
newWithheldCount === 0 &&
nextContent.length <=
previousContent.length;
// Withheld rows count against the portal's
// total, or the grid would keep asking for
// pages the lock will never let it show.
const totalCount = appendStalled
? nextContent.length
: Math.max(
0,
(response.js.total_items ?? 0) -
withheldSeenIds.size
);
patchState(store, {
totalCount: appendStalled
? nextContent.length
: (response.js.total_items ?? 0),
totalCount,
paginatedContent: nextContent,
contentError: null,
appendError: null,
hasMoreChannels: false,
});
skipWithheldPage(
nextContent.length < totalCount
);
return nextContent;
}
@@ -744,16 +918,40 @@ export function withStalkerContent() {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
const itvCache = inject(StalkerItvCacheService);
const parentalLock = inject(ParentalLockService);
/**
* The whole portal's ITV channel list (all categories) when
* cached. `versionFor` establishes the reactive dependency so this
* cached, minus the genres the parental lock withholds.
* `versionFor` establishes the reactive dependency so this
* recomputes when the list becomes ready or is refreshed.
*/
const itvFullChannelList = computed(() => {
const playlist = storeContext.currentPlaylist();
itvCache.versionFor(playlist);
return itvCache.getChannels(playlist) ?? [];
parentalLock.version();
return withoutWithheldStalkerItems(
itvCache.getChannels(playlist) ?? [],
'itv',
withheldStalkerCategoryIds(parentalLock, playlist, 'itv')
);
});
/** The selected section's genres with the withheld ones removed. */
const visibleCategoryResource = computed(() => {
parentalLock.version();
const contentType = storeContext.selectedContentType();
const withheld = withheldStalkerCategoryIds(
parentalLock,
storeContext.currentPlaylist(),
contentType
);
const categories = getCategoriesByType(store, contentType);
return withheld.size === 0
? categories
: categories.filter(
(category) =>
!withheld.has(String(category.category_id))
);
});
/**
@@ -909,7 +1107,9 @@ export function withStalkerContent() {
storeContext.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() => store.contentError()),
getCategoryResource: computed(() =>
getCategoryResource: visibleCategoryResource,
/** Every genre of the section, locked ones included (lock dialog). */
getAllCategoriesForSelectedType: computed(() =>
getCategoriesByType(
store,
storeContext.selectedContentType()
@@ -4,4 +4,5 @@ export * from './stalker-playback-command.utils';
export * from './stalker-player-request.utils';
export * from './stalker-request.utils';
export * from './stalker-content-mappers';
export * from './stalker-parental-lock.utils';
export * from './stalker-sorting';
@@ -0,0 +1,79 @@
import {
isStalkerItemWithheld,
stalkerWithheldRowKey,
withoutWithheldStalkerItems,
} from './stalker-parental-lock.utils';
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
describe('stalker-parental-lock.utils', () => {
const withheld = new Set(['7', '9']);
it('reads the genre from tv_genre_id for live and radio rows', () => {
expect(isStalkerItemWithheld({ tv_genre_id: 7 }, 'itv', withheld)).toBe(
true
);
expect(
isStalkerItemWithheld({ tv_genre_id: '9' }, 'radio', withheld)
).toBe(true);
expect(isStalkerItemWithheld({ tv_genre_id: 8 }, 'itv', withheld)).toBe(
false
);
// A VOD row with a matching category_id is not a live row.
expect(
isStalkerItemWithheld({ category_id: '7' }, 'itv', withheld)
).toBe(false);
});
it('reads category_id for VOD and series rows', () => {
expect(
isStalkerItemWithheld({ category_id: '7' }, 'vod', withheld)
).toBe(true);
expect(
isStalkerItemWithheld({ category_id: 9 }, 'series', withheld)
).toBe(true);
expect(isStalkerItemWithheld({}, 'vod', withheld)).toBe(false);
});
it('returns the same array when nothing is withheld', () => {
const items = [{ category_id: '7' }];
expect(withoutWithheldStalkerItems(items, 'vod', new Set())).toBe(
items
);
expect(withoutWithheldStalkerItems(items, 'vod', withheld)).toEqual([]);
});
});
describe('isStalkerItemWithheld in fail-closed mode', () => {
it('withholds rows without a genre only while everything is withheld', () => {
expect(isStalkerItemWithheld({}, 'itv', ALL_CATEGORIES_WITHHELD)).toBe(
true
);
expect(
isStalkerItemWithheld(
{ name: 'x' } as never,
'vod',
ALL_CATEGORIES_WITHHELD
)
).toBe(true);
expect(isStalkerItemWithheld({}, 'itv', new Set(['9']))).toBe(false);
expect(
withoutWithheldStalkerItems(
[{ tv_genre_id: '1' }, {}],
'itv',
ALL_CATEGORIES_WITHHELD
)
).toEqual([]);
});
});
describe('stalkerWithheldRowKey', () => {
it('keys rows by whichever id field they carry, never by a shared empty string', () => {
expect(stalkerWithheldRowKey({ id: 5 })).toBe('id:5');
expect(stalkerWithheldRowKey({ stream_id: '6' })).toBe('id:6');
expect(stalkerWithheldRowKey({ movie_id: 7 })).toBe('id:7');
expect(stalkerWithheldRowKey({ series_id: 8 })).toBe('id:8');
expect(stalkerWithheldRowKey({ cmd: 'ffmpeg http://a' })).not.toBe(
stalkerWithheldRowKey({ cmd: 'ffmpeg http://b' })
);
});
});
@@ -0,0 +1,69 @@
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
import type { StalkerContentType } from '../stalker-store.contracts';
/** Minimal item shape the parental lock needs: the category a row belongs to. */
export interface StalkerLockableItem {
tv_genre_id?: string | number;
category_id?: string | number;
id?: string | number;
stream_id?: string | number;
movie_id?: string | number;
series_id?: string | number;
cmd?: string;
name?: string;
}
/**
* The key the paging bookkeeping records a withheld row under. Stalker rows
* carry their id in one of several fields; keying every id-less row under
* `''` would count only the first of them as new, and a page of new locked
* rows would then look like a stalled portal and end paging short of later
* visible matches.
*/
export function stalkerWithheldRowKey(item: StalkerLockableItem): string {
const id = item.id ?? item.stream_id ?? item.movie_id ?? item.series_id;
if (id !== undefined && id !== null && String(id) !== '') {
return `id:${String(id)}`;
}
return `row:${String(item.cmd ?? item.name ?? '')}`;
}
/**
* Whether an item belongs to one of the withheld categories. Live and radio
* rows carry their genre in `tv_genre_id`; VOD and series rows carry
* `category_id`. Only matters for portal-wide lists ("All" and search),
* since a withheld category can never be selected on its own.
*/
export function isStalkerItemWithheld(
item: StalkerLockableItem,
contentType: StalkerContentType,
withheldCategoryIds: ReadonlySet<string>
): boolean {
if (withheldCategoryIds.size === 0) {
return false;
}
const categoryId =
contentType === 'itv' || contentType === 'radio'
? item.tv_genre_id
: item.category_id;
if (categoryId === undefined || categoryId === null) {
// A row without a genre is visible under a normal lock set but not
// in fail-closed mode: while the locks are unknown, "no genre" must
// not become the one row the withheld catalog still shows.
return withheldCategoryIds === ALL_CATEGORIES_WITHHELD;
}
return withheldCategoryIds.has(String(categoryId));
}
export function withoutWithheldStalkerItems<T extends StalkerLockableItem>(
items: T[],
contentType: StalkerContentType,
withheldCategoryIds: ReadonlySet<string>
): T[] {
if (withheldCategoryIds.size === 0) {
return items;
}
return items.filter(
(item) => !isStalkerItemWithheld(item, contentType, withheldCategoryIds)
);
}
+1
View File
@@ -4,6 +4,7 @@ export * from './lib/stalker-collection-detail.component';
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
export * from './lib/stalker-catalog-facade.service';
export * from './lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component';
export * from './lib/stalker-feature.routes';
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
export * from './lib/stalker-search/stalker-search.component';
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff. Show more