feat(settings): add PIN-protected parental lock for categories (#285)

Locks are per category (Xtream category ids, Stalker genre ids, M3U group
titles) and kept in one renderer lock store persisted to app_state /
localStorage; `categories.locked` is the SQLite index re-stamped from it.
While the lock is active the DB worker filters every content read, the PWA
data source, the Stalker store and the M3U channel list filter in memory,
and the enforcement service reloads the stores and steps off withheld
selections. Settings → Parental lock sets the PIN (PBKDF2, never in
Settings), the relock timeout and Lock now; lock toggles live in the
Xtream/M3U management dialogs and a new Stalker lock dialog, all behind
the PIN. Backups carry the locks per playlist entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-13 22:48:27 +02:00
1 parent 17b8aa309d
commit 83e70a52c5
130 files changed
+6254 -278

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".
+50 -26
View File
@@ -625,7 +625,7 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation.
`/workspace/xtreams/:id/downloads/:downloadId` and
`/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide
the workspace context panel.
- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The `general` section's "Window on startup" select (`Settings.startupWindowMode`: `normal` / `maximized` / `fullscreen`, Electron only, gated on `RuntimeCapabilitiesService.supportsStartupWindowMode`) is mirrored into the main-process config by the `SETTINGS_UPDATE` handler and read synchronously at the next window creation — the renderer's IndexedDB is unreachable then, so it is the same pattern as `embeddedMpvFrameCopy`; `iptvnator --fullscreen` forces one fullscreen launch without persisting it, and F11 (`WINDOW:TOGGLE_FULLSCREEN`, bound in `WorkspaceKeyboardShortcutsService`, skipped while the player owns `document.fullscreenElement`) is the exit path on Windows/Linux, where the title bar is hidden (contract: `docs/architecture/workspace-shell.md`, "Startup window mode"). The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography
- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `parental`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The `general` section's "Window on startup" select (`Settings.startupWindowMode`: `normal` / `maximized` / `fullscreen`, Electron only, gated on `RuntimeCapabilitiesService.supportsStartupWindowMode`) is mirrored into the main-process config by the `SETTINGS_UPDATE` handler and read synchronously at the next window creation — the renderer's IndexedDB is unreachable then, so it is the same pattern as `embeddedMpvFrameCopy`; `iptvnator --fullscreen` forces one fullscreen launch without persisting it, and F11 (`WINDOW:TOGGLE_FULLSCREEN`, bound in `WorkspaceKeyboardShortcutsService`, skipped while the player owns `document.fullscreenElement`) is the exit path on Windows/Linux, where the title bar is hidden (contract: `docs/architecture/workspace-shell.md`, "Startup window mode"). The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography
**Service Architecture** (Factory Pattern):
@@ -768,7 +768,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path)
- **Schema** (`libs/shared/database/src/lib/schema.ts` — canonical; `apps/electron-backend/src/app/database/schema.ts` is a backwards-compat re-export shim):
- `playlists` - Playlist metadata (M3U, Xtream, Stalker)
- `categories` - Content categories (live, movies, series)
- `categories` - Content categories (live, movies, series); `hidden` (category management) and `locked` (parental lock index, derived from the renderer's lock store)
- `content` - Streams/VOD/series items. Besides the catalog fields it carries what a detail view learned and handed back: `backdrop_url`, plus the TMDB identity (`tmdb_id`, `release_year`, `original_title`) that lets an activity row repeat the detail view's lookup instead of rebuilding a weaker one from the display title
- `favorites` - User favorites
- `recentlyViewed` - Watch history
@@ -809,6 +809,29 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`). Catalog deletes and inserts commit in row-budgeted transactions of ~5,000 rows (`database/operations/catalog-deletion.ts`: per-category row counts → category groups → set-based `DELETE`s scoped to the captured category ids, never playlist-wide, since the worker interleaves requests between commits and a newer import's categories must survive an older refresh; never 100-row autocommit batches, which flush FTS5 segments and re-append index pages to the WAL on every commit, and never one giant transaction, which would starve the main-process and EPG-worker connections past their 5 s `busy_timeout`). Progress events are throttled to one per 100 ms per operation with summed `increment`s (`operation-progress-throttle.ts`); phase starts, totals reached and terminal events are never held back
- Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`)
### Parental Lock
Issue #285. Settings → Parental lock sets a PIN (PBKDF2 hash in `app_state`
/ localStorage, never in `Settings`) and enables the feature
(`Settings.parentalLockEnabled`, mirrored to electron-conf
`PARENTAL_LOCK_ENABLED`). Locks are per category — Xtream category ids,
Stalker genre ids, M3U group titles — kept in one renderer lock store
(`ParentalLockService`, `libs/services/src/lib/parental-lock/`), with
`categories.locked` as the SQLite index re-stamped by
`DB_SET_CATEGORY_LOCKS`. While `active` (enabled and no PIN entered this
session) the DB worker appends `locked = 0` to every content read
(`database/parental-lock-state.ts`, seeded via `workerData` and flipped by the
`parental-lock` port message that `PARENTAL_LOCK_SET_STATE` forwards; a
renderer reload or crash falls back to the mirrored setting), the PWA data
source, the Stalker store and the M3U channel list filter in memory, and
`ParentalLockEnforcementService` reloads the stores and steps off a withheld
selection. Re-lock: restart, `Lock now`, or `parentalLockRelockMinutes` of
idle (playback counts as activity). Lock toggles live in the Xtream/M3U
management dialogs and the new Stalker lock dialog, all behind the PIN.
Phase 2 (favorites/recent/positions, dashboard, global collections, guide,
downloads, remote control) is listed in the contract:
`docs/architecture/parental-lock.md`.
### Xtream Category Management
The Electron Live TV, Movies, and Series category dialog applies Select/Deselect
@@ -1854,30 +1877,31 @@ preventing destination failures from penalizing the initial endpoint. Contracts:
Portal live layouts (Xtream `live`, Stalker `itv`/`radio`) fold their panels
from the outside in, in three nested levels owned by `LiveSidebarState`
(`@iptvnator/portal/shared/util`): `expanded` (categories rail + channels rail
+ player), `categories-hidden` (channels rail + player) and `collapsed`
(player only). `LiveLayoutSidebarStateService` is the single source of truth, per
surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the
shell context sidebar folds the categories rail on
`areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has
a selected category — the live root has no channels header to host the way
back — and always at level 3), the channels rail folds on
`isCollapsedFor('portal')`. While the rail is folded the
channels header turns its title into a category dropdown that opens the same
`WorkspaceContextPanelComponent` as a CDK popover through the
`LIVE_CATEGORIES_POPOVER` token: the workspace shell provides
`WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`,
closed by backdrop, Escape, selection, its footer and any `NavigationStart`),
the live layouts reach it through `createLivePanelsController()` (level
flags, dropdown bridge and focus handoff in one shared object; the token is
optional). `Cmd/Ctrl+B`, the header toggle and the
floating restore handle return to the level the user collapsed from (the
target is session-only; every level is restored as stored per surface).
Folded rails carry `inert`, and
`handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the
replacement affordance only when the activated button was removed or inerted.
M3U and the unified live tab have no categories rail and treat level 2 like
level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md`
("Collapsible Live Sidebar").
- player), `categories-hidden` (channels rail + player) and `collapsed`
(player only). `LiveLayoutSidebarStateService` is the single source of truth, per
surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the
shell context sidebar folds the categories rail on
`areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has
a selected category — the live root has no channels header to host the way
back — and always at level 3), the channels rail folds on
`isCollapsedFor('portal')`. While the rail is folded the
channels header turns its title into a category dropdown that opens the same
`WorkspaceContextPanelComponent` as a CDK popover through the
`LIVE_CATEGORIES_POPOVER` token: the workspace shell provides
`WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`,
closed by backdrop, Escape, selection, its footer and any `NavigationStart`),
the live layouts reach it through `createLivePanelsController()` (level
flags, dropdown bridge and focus handoff in one shared object; the token is
optional). `Cmd/Ctrl+B`, the header toggle and the
floating restore handle return to the level the user collapsed from (the
target is session-only; every level is restored as stored per surface).
Folded rails carry `inert`, and
`handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the
replacement affordance only when the activated button was removed or inerted.
M3U and the unified live tab have no categories rail and treat level 2 like
level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md`
("Collapsible Live Sidebar").
## Live Channel Return
@@ -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'],
@@ -93,6 +93,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<
(
@@ -447,6 +448,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,
@@ -812,14 +815,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),
@@ -829,6 +834,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,89 @@ 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 }),
]);
});
it('re-stamps one playlist/type: clears everything, then locks the listed ids', async () => {
const where = jest.fn().mockResolvedValue(undefined);
const set = jest.fn().mockReturnValue({ where });
const update = jest.fn().mockReturnValue({ set });
const db = { update } as unknown as AppDatabase;
await setCategoryLocks(db, 'playlist-1', 'live', [5, 5, 7, 1.5]);
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 where = jest.fn().mockResolvedValue(undefined);
const set = jest.fn().mockReturnValue({ where });
const update = jest.fn().mockReturnValue({ set });
const db = { update } as unknown as AppDatabase;
await setCategoryLocks(db, 'playlist-1', 'series', []);
expect(set).toHaveBeenCalledTimes(1);
expect(set).toHaveBeenCalledWith({ locked: false });
});
});
@@ -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,35 @@ 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))),
];
await db.update(schema.categories).set({ locked: false }).where(scope);
if (lockedIds.length > 0) {
await db
.update(schema.categories)
.set({ locked: true })
.where(and(scope, inArray(schema.categories.xtreamId, lockedIds)));
}
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);
});
@@ -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';
export default class SettingsEvents {
static bootstrapSettingsEvents(): Electron.IpcMain {
@@ -41,6 +43,16 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
setHostConnectivityGuardEnabled(enabled);
}
// Mirrored so the database worker and a reloaded renderer start locked
// whenever the feature is on. Turning the feature off also releases the
// worker filter immediately; turning it on locks it — the renderer
// announces an explicit unlock through PARENTAL_LOCK_SET_STATE.
if (arg.parentalLockEnabled !== undefined) {
const enabled = arg.parentalLockEnabled === true;
store.set(PARENTAL_LOCK_ENABLED, enabled);
void applyParentalLockState(enabled).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;
}
@@ -28,6 +28,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';
/**
* Extra libmpv options for embedded sessions, one "key=value" per line, as
* typed in Settings > Playback. Mirrored here by the SETTINGS_UPDATE handler
@@ -56,6 +64,7 @@ export type StoreType = {
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
[STARTUP_WINDOW_MODE]: StartupWindowMode;
[PORTAL_CONNECTIVITY_GUARD]: boolean;
[PARENTAL_LOCK_ENABLED]: boolean;
};
// Export singleton store instance
@@ -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) {
+2
View File
@@ -26,6 +26,7 @@ import { shutdownVlcSession } from './app/events/vlc-session.service';
import PlaylistEvents from './app/events/playlist.events';
import PlaylistOpenEvents from './app/events/playlist-open.events';
import RemoteControlEvents from './app/events/remote-control.events';
import ParentalLockEvents from './app/events/parental-lock.events';
import SettingsEvents from './app/events/settings.events';
import SharedEvents from './app/events/shared.events';
import SquirrelEvents from './app/events/squirrel.events';
@@ -160,6 +161,7 @@ export default class Main {
SharedEvents.bootstrapSharedEvents();
PlayerEvents.bootstrapPlayerEvents();
SettingsEvents.bootstrapSettingsEvents();
ParentalLockEvents.bootstrapParentalLockEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 980 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

+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.afterEach(async ({ page }, testInfo) => {
+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,
@@ -96,6 +98,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
@@ -25,6 +25,7 @@ import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { filter, take } from 'rxjs';
import {
DataService,
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
EpgSourceSettingsService,
@@ -38,6 +39,7 @@ import {
createDevLogger,
} from '@iptvnator/shared/interfaces';
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';
@@ -76,6 +78,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);
@@ -101,6 +107,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;
+2
View File
@@ -39,6 +39,7 @@ 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';
@@ -159,6 +160,7 @@ export const appConfig: ApplicationConfig = {
useExisting: AppStalkerPlaylistConnectionEditorService,
},
...provideWorkspaceShellActions(),
...provideParentalLockPrompt(),
...provideXtreamDataSource(),
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
@@ -0,0 +1,155 @@
import { effect, inject, Injectable, untracked } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { ChannelActions, selectActive } from '@iptvnator/m3u-state';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { ParentalLockService } from '@iptvnator/services';
import {
toParentalLockStalkerCategoryType,
toParentalLockXtreamCategoryType,
} from '@iptvnator/shared/interfaces';
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
const XTREAM_ROUTE =
/^\/workspace\/xtreams\/([^/?#]+)\/(live|vod|series)(?:\/(\d+))?/;
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 stalkerStore = inject(StalkerStore);
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;
start(): void {
if (this.started) {
return;
}
this.started = true;
this.parentalLock.registerBusyProbe(() =>
this.keepAwake.hasPlayingVideo()
);
effect(() => {
const version = this.parentalLock.version();
untracked(() => {
if (version === this.lastVersion) {
return;
}
const first = this.lastVersion === -1;
this.lastVersion = version;
if (!first) {
void this.apply();
}
});
});
}
private async apply(): Promise<void> {
await Promise.all([this.applyXtream(), this.applyStalker()]);
this.applyM3u();
}
private async applyXtream(): Promise<void> {
const playlistId = this.xtreamStore.playlistId?.();
if (!playlistId) {
return;
}
await this.xtreamStore.reloadCategories();
await this.xtreamStore.reloadCachedContent();
const match = XTREAM_ROUTE.exec(this.router.url);
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
if (selectedCategoryId === null) {
return;
}
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
const categories = this.xtreamStore.getCategoriesBySelectedType();
const stillVisible = categories.some(
(category) =>
Number(
(category as { id?: number | string }).id ??
(category as { category_id?: string }).category_id
) === Number(selectedCategoryId)
);
if (stillVisible) {
return;
}
this.xtreamStore.setSelectedItem(null);
this.xtreamStore.setSelectedCategory(null);
if (match && match[1] === playlistId && categoryType) {
void this.router.navigate([
'/workspace',
'xtreams',
match[1],
match[2],
]);
}
}
private async applyStalker(): Promise<void> {
const playlist = this.stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
if (!playlistId) {
return;
}
const contentType = toParentalLockStalkerCategoryType(
this.stalkerStore.selectedContentType()
);
const selectedCategoryId = this.stalkerStore.selectedCategoryId();
if (
!contentType ||
!selectedCategoryId ||
!this.parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedCategoryId
)
) {
return;
}
this.stalkerStore.clearSelectedItem();
this.stalkerStore.setSelectedCategory(null);
const match = STALKER_ROUTE.exec(this.router.url);
if (match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'stalker',
match[1],
match[2],
]);
}
}
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;
}
}
@@ -0,0 +1,42 @@
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';
import { ParentalLockPinDialogComponent } from '@iptvnator/ui/components';
/**
* Application implementation of the parental lock prompt: the shared PIN
* dialog from `@iptvnator/ui/components`, which the data-access lock
* service cannot import itself.
*/
@Injectable({ providedIn: 'root' })
export class AppParentalLockPromptService implements ParentalLockPrompt {
private readonly dialog = inject(MatDialog);
async requestPin(
request: ParentalLockPromptRequest
): Promise<string | null> {
const dialogRef = ParentalLockPinDialogComponent.open(this.dialog, {
mode: request.mode,
verify: request.verify,
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,
},
];
}
@@ -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;
@@ -16,6 +16,7 @@ import {
normalizeEmbeddedMpvExtraOptions,
normalizeExternalPlayerArguments,
normalizeEpgOffsetMinutes,
normalizeParentalLockRelockMinutes,
normalizeStartupWindowMode,
Settings,
StartupBehavior,
@@ -217,6 +218,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',
@@ -194,6 +194,12 @@ export function buildSettingsSectionNavItems({
icon: 'movie',
visible: true,
},
{
id: 'parental',
label: 'SETTINGS.NAV_PARENTAL',
icon: 'family_restroom',
visible: true,
},
{
id: 'backup',
label: 'SETTINGS.NAV_BACKUP',
@@ -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">
<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)="toggleEnabled.emit($event.checked)"
[attr.aria-label]="'SETTINGS.PARENTAL_LOCK.ENABLE' | translate"
></mat-slide-toggle>
</div>
</div>
@if (enabled()) {
<div class="setting-item">
<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">
<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()"
(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">
<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">
<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,44 @@
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 { 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-lock-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>();
relockLabelKey(minutes: ParentalLockRelockMinutes): string {
return minutes === 0
? 'SETTINGS.PARENTAL_LOCK.RELOCK_NEVER'
: 'SETTINGS.PARENTAL_LOCK.RELOCK_MINUTES';
}
}
@@ -0,0 +1,86 @@
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(() => this.parentalLock.setRelockMinutes(minutes));
}
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);
}
}
}
@@ -356,6 +356,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 }
);
@@ -363,6 +367,8 @@ describe('SettingsComponent form', () => {
...component.settingsForm.value,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
parentalLockEnabled: false,
parentalLockRelockMinutes: 15,
});
});
@@ -63,6 +63,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"
@@ -43,6 +43,8 @@ import {
SETTINGS_STARTUP_WINDOW_MODE_OPTIONS,
SETTINGS_THEME_OPTIONS,
} from './settings-options';
import { SettingsParentalLockFacade } from './settings-parental-lock.facade';
import { SettingsParentalLockSectionComponent } from './settings-parental-lock-section.component';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
@@ -88,6 +90,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
SettingsDashboardSectionComponent,
SettingsEpgSectionComponent,
SettingsGeneralSectionComponent,
SettingsParentalLockSectionComponent,
SettingsPlaybackSectionComponent,
SettingsRemoteControlSectionComponent,
SettingsResetSectionComponent,
@@ -99,6 +102,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
SettingsEmbeddedMpvFacade,
SettingsEpgFacade,
SettingsFormFacade,
SettingsParentalLockFacade,
SettingsPlaylistResetFacade,
SettingsRemoteControlFacade,
SettingsSnackbarService,
@@ -113,6 +117,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);
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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": "داكن",
@@ -1646,7 +1671,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": "جارٍ الحفاظ على بيانات مكتبتك...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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": "داكن",
@@ -1646,7 +1671,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": "كنحافظو على معطيات المكتبة ديالك...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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": "Цёмная",
@@ -1646,7 +1671,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": "Захаванне даных вашай бібліятэкі...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 nutze die Schloss-Symbole im Verwaltungsdialog der Kategorienliste (Xtream, M3U-Gruppen) oder die Schloss-Schaltfläche über den Stalker-Kategorien.",
"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",
@@ -1646,7 +1671,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...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Gesperrt",
"LOCK_CATEGORY": "Kategorie sperren",
"UNLOCK_CATEGORY": "Kategorie 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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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": "Σκούρο",
@@ -1646,7 +1671,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": "Διατήρηση των δεδομένων της βιβλιοθήκης σας...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1646,7 +1671,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...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1646,7 +1671,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...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1646,7 +1671,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...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1646,7 +1671,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…",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1646,7 +1671,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...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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": "ダーク",
@@ -1646,7 +1671,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": "ライブラリデータを保持しています...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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": "어둡게",
@@ -1646,7 +1671,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": "라이브러리 데이터 보존 중...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1646,7 +1671,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...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1646,7 +1671,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...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1646,7 +1671,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...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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": "Откройте источник и используйте значки замка в диалоге управления категориями (Xtream, группы M3U) или кнопку замка над категориями Stalker.",
"DISCLAIMER": "Это детская блокировка, а не защита: файлы данных приложения доступны любому, у кого есть доступ к этому компьютеру.",
"ENABLED_TOAST": "Родительский контроль включён",
"DISABLED_TOAST": "Родительский контроль выключен",
"PIN_CHANGED_TOAST": "PIN изменён",
"LOCKED_TOAST": "Заблокировано"
}
},
"THEMES": {
"DARK_THEME": "Тёмная",
@@ -1646,7 +1671,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": "Сохраняем данные вашей библиотеки...",
@@ -1834,5 +1863,35 @@
"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": "Разблокировать"
},
"LOCKED_COUNT": "Заблокировано",
"LOCK_CATEGORY": "Заблокировать категорию",
"UNLOCK_CATEGORY": "Разблокировать категорию",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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",
@@ -1646,7 +1671,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...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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": "深色",
@@ -1646,7 +1671,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": "正在保留您的媒体库数据…",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+61 -2
View File
@@ -530,7 +530,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 the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.",
"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": "深色",
@@ -1646,7 +1671,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": "正在保留你的媒體庫資料...",
@@ -1834,5 +1863,35 @@
"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"
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
"UNLOCK_CATEGORY": "Unlock category",
"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"
}
}
+159
View File
@@ -0,0 +1,159 @@
# 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 asks
for the current PIN and 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.
- 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). Active playback of a built-in web
player counts as interaction, so a film never locks half way.
- 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.
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.
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. 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.
### 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.
- **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories and
streams at read time; the same reloads apply.
- **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. 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.
- Lock toggles in the Xtream category-management dialog and the M3U group
dialog (rendered only while the feature is on), plus a new Stalker
`StalkerCategoryLockDialogComponent` reached from a lock button above the
categories rail; it offers "Lock adult (18+)" for genres the portal flags
`censored`. Opening any of these while locked asks for the PIN first.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
## Backup
`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 and the command palette's channel search
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/`,
`apps/web/src/app/services/parental-lock-prompt.service.ts`,
`parental-lock-enforcement.service.ts`,
`apps/web/src/app/settings/settings-parental-lock*`,
`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`).
@@ -9,7 +9,12 @@ 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 {
StalkerCategoryItem,
StalkerContentItem,
@@ -31,8 +36,25 @@ import {
filterItvChannelsByGenre,
toStalkerContentItem,
toStalkerItvChannel,
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
): Set<string> {
const playlistId = playlist?._id;
if (!playlistId || !parentalLock.active()) {
return new Set();
}
return new Set(parentalLock.lockedStalkerIds(playlistId, contentType));
}
/**
* Content/categories/channels feature state.
*/
@@ -225,7 +247,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;
@@ -387,6 +410,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,
@@ -432,15 +459,25 @@ export function withStalkerContent() {
}
const categoryParam = params.category || '*';
const withheldCategoryIds =
withheldStalkerCategoryIds(
parentalLock,
playlist,
params.contentType
);
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
@@ -495,6 +532,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
@@ -582,11 +621,15 @@ export function withStalkerContent() {
return [];
}
const newItems = response.js.data.map((item) =>
toStalkerContentItem(
item,
playlist.portalUrl ?? ''
)
const newItems = withoutWithheldStalkerItems(
response.js.data.map((item) =>
toStalkerContentItem(
item,
playlist.portalUrl ?? ''
)
),
params.contentType,
withheldCategoryIds
);
if (
@@ -708,16 +751,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))
);
});
/**
@@ -855,7 +922,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,42 @@
import {
isStalkerItemWithheld,
withoutWithheldStalkerItems,
} from './stalker-parental-lock.utils';
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([]);
});
});
@@ -0,0 +1,45 @@
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;
}
/**
* 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;
return (
categoryId !== undefined &&
categoryId !== null &&
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';
@@ -0,0 +1,105 @@
<h2 mat-dialog-title>{{ 'PARENTAL_LOCK.MANAGE_LOCKS' | translate }}</h2>
<mat-dialog-content>
<p class="lock-dialog__description">
{{ 'PARENTAL_LOCK.MANAGE_LOCKS_DESCRIPTION' | translate }}
</p>
<div class="header-actions">
<div class="selection-info">
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong> / {{ categories().length }}
</div>
<div class="bulk-actions">
@if (censoredCount() > 0) {
<button
mat-stroked-button
type="button"
data-test-id="stalker-lock-censored"
[disabled]="isSaving()"
(click)="lockCensored()"
>
{{ 'PARENTAL_LOCK.LOCK_CENSORED' | translate }}
</button>
}
<button
mat-stroked-button
type="button"
[disabled]="isSaving() || lockedCount() === 0"
(click)="unlockAll()"
>
{{ 'PARENTAL_LOCK.UNLOCK_ALL' | translate }}
</button>
</div>
</div>
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER' | translate"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
<div class="categories-list">
@for (category of filteredCategories(); track category.id) {
<button
type="button"
class="category-item"
[class.category-item--locked]="category.locked"
[attr.aria-pressed]="category.locked"
[attr.data-test-id]="'stalker-category-lock-' + category.id"
(click)="toggle(category)"
>
<mat-icon class="category-item__icon">{{
category.locked ? 'lock' : 'lock_open'
}}</mat-icon>
<span class="category-name">{{ category.name }}</span>
@if (category.censored) {
<span
class="censored-chip"
[matTooltip]="'PARENTAL_LOCK.CENSORED_HINT' | translate"
>18+</span
>
}
</button>
} @empty {
<div class="empty-message">
{{ 'XTREAM.CATEGORY_MANAGEMENT.NO_RESULTS' | translate }}
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()" [disabled]="isSaving()">
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
color="primary"
type="button"
data-test-id="stalker-category-lock-save"
(click)="save()"
[disabled]="isSaving()"
>
@if (isSaving()) {
<mat-spinner diameter="20" />
} @else {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SAVE' | translate }}
}
</button>
</mat-dialog-actions>
@@ -0,0 +1,161 @@
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.lock-dialog__description {
margin: 0;
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
}
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.selection-info {
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
strong {
color: var(--mat-sys-primary);
}
}
.bulk-actions {
display: flex;
gap: 8px;
button {
font-size: 12px;
}
}
.search-inline {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
min-height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
cursor: text;
&:focus-within {
border-color: var(--mat-sys-primary);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.84rem;
min-width: 0;
&::-webkit-search-cancel-button {
display: none;
}
}
&__clear {
width: 24px;
height: 24px;
min-width: 24px;
padding: 0;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.categories-list {
flex: 1;
min-height: 200px;
max-height: 50vh;
overflow-y: auto;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
}
.category-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 0 10px;
min-height: 32px;
border: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover {
background-color: var(--mat-sys-surface-container);
}
&:not(:last-child) {
border-bottom: 1px solid var(--mat-sys-surface-container);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
&--locked .category-item__icon {
color: var(--mat-sys-primary);
}
}
.category-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.censored-chip {
font-size: 0.6875rem;
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--mat-sys-error) 16%, transparent);
color: var(--mat-sys-error);
}
.empty-message {
padding: 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8125rem;
}
:host ::ng-deep .mat-mdc-dialog-content {
min-width: 440px;
max-width: 600px;
}
@@ -0,0 +1,159 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerCategoryItem } from '@iptvnator/portal/stalker/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { ParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
export interface StalkerCategoryLockDialogData {
playlistId: string;
contentType: ParentalLockStalkerCategoryType;
/** Every genre of the section, the "All" pseudo-category excluded. */
categories: StalkerCategoryItem[];
}
interface LockableCategory {
readonly id: string;
readonly name: string;
readonly censored: boolean;
readonly locked: boolean;
}
/**
* Parental lock picker for a Stalker section's genres. Stalker has no
* hide/show dialog (its genres are never persisted), so this is the only
* category dialog for that portal type; it exists while the lock is on.
*/
@Component({
selector: 'app-stalker-category-lock-dialog',
imports: [
MatButtonModule,
MatDialogModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
TranslatePipe,
],
templateUrl: './stalker-category-lock-dialog.component.html',
styleUrl: './stalker-category-lock-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerCategoryLockDialogComponent {
readonly data = inject<StalkerCategoryLockDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(
MatDialogRef<StalkerCategoryLockDialogComponent, boolean>
);
private readonly parentalLock = inject(ParentalLockService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
readonly searchTerm = signal('');
readonly isSaving = signal(false);
readonly categories = signal<LockableCategory[]>(this.buildDraft());
readonly filteredCategories = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
const categories = this.categories();
return term
? categories.filter((category) =>
category.name.toLowerCase().includes(term)
)
: categories;
});
readonly lockedCount = computed(
() => this.categories().filter((category) => category.locked).length
);
readonly censoredCount = computed(
() => this.categories().filter((category) => category.censored).length
);
private buildDraft(): LockableCategory[] {
const locked = new Set(
this.parentalLock.lockedStalkerIds(
this.data.playlistId,
this.data.contentType
)
);
return this.data.categories.map((category) => {
const id = String(category.category_id);
return {
id,
name: category.category_name,
censored: category.censored === true,
locked: locked.has(id),
};
});
}
clearSearch(): void {
this.searchTerm.set('');
}
toggle(category: LockableCategory): void {
this.categories.update((categories) =>
categories.map((current) =>
current.id === category.id
? { ...current, locked: !current.locked }
: current
)
);
}
/** Pre-checks the genres the portal itself flags as adult (`censored`). */
lockCensored(): void {
this.categories.update((categories) =>
categories.map((current) =>
current.censored ? { ...current, locked: true } : current
)
);
}
unlockAll(): void {
this.categories.update((categories) =>
categories.map((current) => ({ ...current, locked: false }))
);
}
async save(): Promise<void> {
this.isSaving.set(true);
try {
const saved = await this.parentalLock.setStalkerLocks(
this.data.playlistId,
this.data.contentType,
this.categories()
.filter((category) => category.locked)
.map((category) => category.id)
);
if (!saved) {
this.snackBar.open(
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED'),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
return;
}
this.dialogRef.close(true);
} finally {
this.isSaving.set(false);
}
}
cancel(): void {
this.dialogRef.close(false);
}
}
@@ -84,7 +84,9 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
playlistId,
remoteCategories,
'movies',
undefined
undefined,
// Parental lock: no locks stored for the playlist.
[]
);
expect(result).toEqual([dbCategory]);
expect(onPhaseChange.mock.calls).toEqual([
@@ -174,7 +176,8 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
playlistId,
expect.any(Array),
'live',
[5]
[5],
[]
);
});
@@ -207,7 +210,11 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
.mockResolvedValueOnce([]);
await expect(
harness.dataSource.getCategories(playlistId, credentials, 'live')
harness.dataSource.getCategories(
playlistId,
credentials,
'live'
)
).rejects.toThrow('portal unreachable');
await harness.dataSource.getCategories(
@@ -1,6 +1,7 @@
import { inject, Injectable } from '@angular/core';
import {
DatabaseService,
ParentalLockService,
PlaybackPositionService,
XtreamPendingRestoreService,
XtreamImportStatus,
@@ -46,6 +47,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
XtreamPendingRestoreService
);
private readonly apiService = inject(XtreamApiService);
private readonly parentalLock = inject(ParentalLockService);
private readonly categoryRequests = new Map<
string,
Promise<XtreamCategoryFromDb[]>
@@ -213,7 +215,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId,
remoteData,
dbType,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
// Locks live in the renderer's store; the fresh rows get the
// SQLite mirror stamped on insert so a refresh keeps them.
this.parentalLock.lockedXtreamIds(playlistId, dbType)
);
}
@@ -16,7 +16,7 @@ import {
XtreamApiService,
XtreamCredentials,
} from '../services/xtream-api.service';
import { PlaylistsService } from '@iptvnator/services';
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
import { firstValueFrom } from 'rxjs';
import {
DbCategoryType,
@@ -81,6 +81,7 @@ type StoredXtreamPlaylistData = Omit<XtreamPlaylistData, 'password'> & {
export class PwaXtreamDataSource implements IXtreamDataSource {
private readonly apiService = inject(XtreamApiService);
private readonly injector = inject(Injector);
private readonly parentalLock = inject(ParentalLockService);
private readonly logger = createLogger('PwaXtreamDataSource');
private readonly contentTypes = ['live', 'movie', 'series'] as const;
@@ -331,7 +332,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Check in-memory cache first
const cachedCategories = this.categoryCache.get(cacheKey);
if (cachedCategories) {
return cachedCategories;
return this.withoutLockedCategories(
playlistId,
type,
cachedCategories
);
}
// Fetch from API
@@ -344,7 +349,58 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Cache in memory
this.categoryCache.set(cacheKey, categories);
return categories;
return this.withoutLockedCategories(playlistId, type, categories);
}
/**
* Parental lock (PWA): there is no SQLite worker to filter reads, so the
* withheld categories are dropped here, at the same boundary the
* Electron source reads them through.
*/
private withheldCategoryIds(
playlistId: string,
type: CategoryType | StreamType
): Set<string> {
if (!this.parentalLock.active()) {
return new Set();
}
const dbType =
type === 'vod' || type === 'movie'
? 'movies'
: type === 'series'
? 'series'
: 'live';
return new Set(
this.parentalLock
.lockedXtreamIds(playlistId, dbType)
.map((id) => String(id))
);
}
private withoutLockedCategories(
playlistId: string,
type: CategoryType,
categories: XtreamCategory[]
): XtreamCategory[] {
const withheld = this.withheldCategoryIds(playlistId, type);
return withheld.size === 0
? categories
: categories.filter(
(category) => !withheld.has(String(category.category_id))
);
}
private withoutLockedContent<T extends { category_id?: string | number }>(
playlistId: string,
type: StreamType,
items: T[]
): T[] {
const withheld = this.withheldCategoryIds(playlistId, type);
return withheld.size === 0
? items
: items.filter(
(item) => !withheld.has(String(item.category_id ?? ''))
);
}
async getAllCategories(
@@ -416,8 +472,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Check in-memory cache first
const cachedContent = this.contentCache.get(cacheKey);
if (cachedContent) {
return cachedContent as
XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[];
return this.withoutLockedContent(
playlistId,
type,
cachedContent
) as XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[];
}
// Fetch from API
@@ -444,7 +503,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Cache in memory
this.contentCache.set(cacheKey, content);
return content;
return this.withoutLockedContent(playlistId, type, content);
}
async getCachedContent(
@@ -1495,6 +1495,52 @@ export function withContent() {
}
},
/**
* Re-reads the already imported streams from the data source
* cache without touching the import bookkeeping. Used when
* the parental lock flips: the SQLite worker (Electron) and
* the PWA data source filter locked categories at read time,
* so the in-memory catalog must be rebuilt from them.
*/
async reloadCachedContent(): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx || !store.isContentInitialized()) {
return;
}
const loadStates = store.contentLoadStateByType();
try {
if (loadStates.live === 'ready') {
const live = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'live'
)) as XtreamLiveStream[];
patchState(store, { liveStreams: live });
}
if (loadStates.vod === 'ready') {
const vod = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'movie'
)) as XtreamVodStream[];
patchState(store, {
vodStreams: vod,
vodStreamsPlaylistId: ctx.playlistId,
});
}
if (loadStates.series === 'ready') {
const series = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'series'
)) as XtreamSerieItem[];
patchState(store, { serialStreams: series });
}
} catch (error) {
logger.error('Error reloading cached content', error);
}
},
/**
* Update import progress
*/
@@ -11,6 +11,12 @@
<div class="selection-info">
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
@if (showLocks()) {
<span class="selection-info__locks">
· {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
}
</div>
<div class="bulk-actions">
<button
@@ -85,6 +91,35 @@
<span class="item-count"
>({{ getItemCount(category) }})</span
>
@if (showLocks()) {
<button
type="button"
mat-icon-button
class="lock-toggle"
[class.lock-toggle--locked]="category.lockedDraft"
[attr.aria-pressed]="category.lockedDraft"
[attr.data-test-id]="
'category-lock-' + category.xtream_id
"
[matTooltip]="
(category.lockedDraft
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
[attr.aria-label]="
(category.lockedDraft
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
(click)="toggleLock(category, $event)"
>
<mat-icon>{{
category.lockedDraft ? 'lock' : 'lock_open'
}}</mat-icon>
</button>
}
</div>
} @empty {
<div class="empty-message">
@@ -168,3 +168,27 @@ mat-dialog-actions {
}
}
}
.selection-info__locks {
margin-left: 4px;
}
.lock-toggle {
width: 28px;
height: 28px;
min-width: 28px;
padding: 0;
line-height: 28px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&--locked {
color: var(--mat-sys-primary);
}
}
@@ -16,8 +16,13 @@ import {
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { DatabaseService, XCategoryFromDb } from '@iptvnator/services';
import {
DatabaseService,
ParentalLockService,
XCategoryFromDb,
} from '@iptvnator/services';
import { createLogger } from '@iptvnator/portal/shared/util';
export interface CategoryManagementDialogData {
@@ -28,6 +33,8 @@ export interface CategoryManagementDialogData {
interface CategoryWithSelection extends XCategoryFromDb {
selected: boolean;
/** Parental lock draft; only rendered while the lock is enabled. */
lockedDraft: boolean;
}
@Component({
@@ -38,6 +45,7 @@ interface CategoryWithSelection extends XCategoryFromDb {
MatCheckboxModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
TranslatePipe,
],
templateUrl: './category-management-dialog.component.html',
@@ -46,12 +54,20 @@ interface CategoryWithSelection extends XCategoryFromDb {
})
export class CategoryManagementDialogComponent implements OnInit {
private readonly dbService = inject(DatabaseService);
private readonly parentalLock = inject(ParentalLockService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogRef = inject(
MatDialogRef<CategoryManagementDialogComponent>
);
readonly data = inject<CategoryManagementDialogData>(MAT_DIALOG_DATA);
private readonly logger = createLogger('CategoryManagementDialog');
/** Lock toggles exist only while the parental lock feature is on. */
readonly showLocks = this.parentalLock.enabled;
readonly lockedCount = computed(
() => this.categories().filter((c) => c.lockedDraft).length
);
readonly isLoading = signal(true);
readonly isSaving = signal(false);
readonly categories = signal<CategoryWithSelection[]>([]);
@@ -89,10 +105,16 @@ export class CategoryManagementDialogComponent implements OnInit {
try {
const type = this.getDbType();
const allCategories = await this.waitForCategories(type);
// The renderer's lock store is authoritative; the row's `locked`
// column is only its SQLite mirror.
const lockedIds = new Set(
this.parentalLock.lockedXtreamIds(this.data.playlistId, type)
);
this.categories.set(
allCategories.map((c) => ({
...c,
selected: !c.hidden,
lockedDraft: lockedIds.has(c.xtream_id),
}))
);
} catch (error) {
@@ -154,6 +176,15 @@ export class CategoryManagementDialogComponent implements OnInit {
);
}
toggleLock(category: CategoryWithSelection, event?: Event): void {
event?.stopPropagation();
this.categories.update((cats) =>
cats.map((c) =>
c.id === category.id ? { ...c, lockedDraft: !c.lockedDraft } : c
)
);
}
selectAll(): void {
this.setFilteredSelection(true);
}
@@ -187,14 +218,25 @@ export class CategoryManagementDialogComponent implements OnInit {
await this.dbService.updateCategoryVisibility(toShow, false);
}
if (this.showLocks()) {
const saved = await this.parentalLock.setXtreamLocks(
this.data.playlistId,
this.getDbType(),
categories
.filter((c) => c.lockedDraft)
.map((c) => c.xtream_id)
);
if (!saved) {
throw new Error('Saving category locks failed');
}
}
this.dialogRef.close(true);
} catch (error) {
this.logger.error('Error saving category visibility', error);
inject(MatSnackBar).open(
'Failed to save category visibility',
'Close',
{ duration: 3000 }
);
this.snackBar.open('Failed to save category visibility', 'Close', {
duration: 3000,
});
} finally {
this.isSaving.set(false);
}
+1
View File
@@ -9,6 +9,7 @@ export * from './lib/playback-position-runtime-bridge.service';
export * from './lib/playback-position.service';
export * from './lib/playlist-delete-cleanup.token';
export * from './lib/playlist-delete-action.service';
export * from './lib/parental-lock';
export * from './lib/playlist-backup.service';
export * from './lib/playlist-cross-context-lock';
export * from './lib/playlist-refresh.service';
@@ -24,6 +24,8 @@ export interface XCategoryFromDb {
type: 'movies' | 'live' | 'series';
xtream_id: number;
hidden: boolean;
/** Parental lock index; see ParentalLockService. */
locked: boolean;
}
export interface XtreamContent {
@@ -429,16 +431,43 @@ export class DatabaseService {
playlistId: string,
categories: XtreamCategory[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[]
): Promise<void> {
await window.electron.dbSaveCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds
);
}
/**
* Re-stamps the parental lock index of one playlist/type from provider
* category ids. False when the bridge is missing or the write failed.
*/
async setCategoryLocks(
playlistId: string,
type: 'live' | 'movies' | 'series',
lockedXtreamIds: number[]
): Promise<boolean> {
if (typeof window.electron?.dbSetCategoryLocks !== 'function') {
return false;
}
try {
await window.electron.dbSetCategoryLocks(
playlistId,
type,
lockedXtreamIds
);
return true;
} catch (error) {
console.error('Error updating category locks:', error);
return false;
}
}
/**
* Get all categories for a playlist (including hidden, for management dialog)
*/
@@ -0,0 +1,4 @@
export * from './parental-lock-idle-timer';
export * from './parental-lock-prompt.token';
export * from './parental-lock-storage';
export * from './parental-lock.service';
@@ -0,0 +1,133 @@
import {
PARENTAL_LOCK_BUSY_RECHECK_MS,
ParentalLockIdleTimer,
} from './parental-lock-idle-timer';
interface FakeScheduler {
now: number;
timers: Map<number, { at: number; callback: () => void }>;
advance(ms: number): void;
}
function createScheduler(): FakeScheduler {
let nextHandle = 1;
const scheduler: FakeScheduler = {
now: 0,
timers: new Map(),
advance(ms: number) {
const target = scheduler.now + ms;
while (true) {
const due = [...scheduler.timers.entries()]
.filter(([, timer]) => timer.at <= target)
.sort((a, b) => a[1].at - b[1].at)[0];
if (!due) {
break;
}
scheduler.timers.delete(due[0]);
scheduler.now = due[1].at;
due[1].callback();
}
scheduler.now = target;
},
};
return Object.assign(scheduler, {
setTimeout: (callback: () => void, delay: number) => {
const handle = nextHandle++;
scheduler.timers.set(handle, { at: scheduler.now + delay, callback });
return handle;
},
clearTimeout: (handle: number) => {
scheduler.timers.delete(handle);
},
});
}
function createTarget() {
const listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
return {
listeners,
addEventListener: jest.fn((type: string, listener) => {
listeners.set(type, (listeners.get(type) ?? new Set()).add(listener));
}),
removeEventListener: jest.fn((type: string, listener) => {
listeners.get(type)?.delete(listener);
}),
fire(type: string) {
for (const listener of listeners.get(type) ?? []) {
(listener as EventListener)(new Event(type));
}
},
};
}
function createTimer(options: { busy?: () => boolean } = {}) {
const scheduler = createScheduler() as FakeScheduler & {
setTimeout: (callback: () => void, delay: number) => number;
clearTimeout: (handle: number) => void;
};
const target = createTarget();
const onExpire = jest.fn();
const timer = new ParentalLockIdleTimer({
onExpire,
isBusy: options.busy,
now: () => scheduler.now,
setTimeout: scheduler.setTimeout,
clearTimeout: scheduler.clearTimeout,
target: target as unknown as Document,
});
return { timer, scheduler, target, onExpire };
}
describe('ParentalLockIdleTimer', () => {
it('expires after the configured minutes without interaction', () => {
const { timer, scheduler, onExpire } = createTimer();
timer.arm(15);
scheduler.advance(14 * 60_000);
expect(onExpire).not.toHaveBeenCalled();
scheduler.advance(60_000);
expect(onExpire).toHaveBeenCalledTimes(1);
expect(timer.armed).toBe(false);
});
it('restarts the countdown on user interaction', () => {
const { timer, scheduler, target, onExpire } = createTimer();
timer.arm(5);
scheduler.advance(4 * 60_000);
target.fire('keydown');
scheduler.advance(4 * 60_000);
expect(onExpire).not.toHaveBeenCalled();
scheduler.advance(60_000);
expect(onExpire).toHaveBeenCalledTimes(1);
});
it('defers expiry while playback is busy and fires once it stops', () => {
let busy = true;
const { timer, scheduler, onExpire } = createTimer({ busy: () => busy });
timer.arm(5);
scheduler.advance(5 * 60_000);
expect(onExpire).not.toHaveBeenCalled();
scheduler.advance(PARENTAL_LOCK_BUSY_RECHECK_MS);
expect(onExpire).not.toHaveBeenCalled();
busy = false;
scheduler.advance(PARENTAL_LOCK_BUSY_RECHECK_MS);
expect(onExpire).toHaveBeenCalledTimes(1);
});
it('never fires with 0 minutes and stops listening once disarmed', () => {
const { timer, scheduler, target, onExpire } = createTimer();
timer.arm(0);
expect(timer.armed).toBe(false);
expect(target.addEventListener).not.toHaveBeenCalled();
timer.arm(1);
expect(target.addEventListener).toHaveBeenCalled();
timer.disarm();
scheduler.advance(10 * 60_000);
expect(onExpire).not.toHaveBeenCalled();
expect(target.removeEventListener).toHaveBeenCalled();
});
});
@@ -0,0 +1,142 @@
/**
* Re-lock timer for the parental lock.
*
* Counts minutes since the last user interaction while unlocked. Playback is
* treated as interaction through `isBusy`: a film that runs for two hours
* without a click must not lock itself half way, so an expiry that lands on
* active playback is deferred and re-checked instead of fired.
*
* Listener shape mirrors `ControlsSurface` (document-level, capture phase,
* passive) so it costs nothing per event beyond a timestamp write.
*/
const ACTIVITY_EVENTS: readonly string[] = [
'pointerdown',
'pointermove',
'keydown',
'wheel',
'touchstart',
];
/** How often a deferred expiry re-checks whether playback has stopped. */
export const PARENTAL_LOCK_BUSY_RECHECK_MS = 60_000;
export interface ParentalLockIdleTimerOptions {
onExpire: () => void;
/** True while something (playback) should keep the app unlocked. */
isBusy?: () => boolean;
/** Injectable clock and scheduler for tests. */
now?: () => number;
setTimeout?: (callback: () => void, delayMs: number) => number;
clearTimeout?: (handle: number) => void;
target?: Pick<Document, 'addEventListener' | 'removeEventListener'>;
}
export class ParentalLockIdleTimer {
private readonly onExpire: () => void;
private readonly isBusy: () => boolean;
private readonly now: () => number;
private readonly schedule: (callback: () => void, delayMs: number) => number;
private readonly cancel: (handle: number) => void;
private readonly target: Pick<
Document,
'addEventListener' | 'removeEventListener'
> | null;
private timeoutMs = 0;
private lastActivityAt = 0;
private handle: number | null = null;
private listening = false;
private readonly onActivity = () => {
this.lastActivityAt = this.now();
};
constructor(options: ParentalLockIdleTimerOptions) {
this.onExpire = options.onExpire;
this.isBusy = options.isBusy ?? (() => false);
this.now = options.now ?? (() => Date.now());
this.schedule =
options.setTimeout ??
((callback, delayMs) =>
window.setTimeout(callback, delayMs) as unknown as number);
this.cancel =
options.clearTimeout ?? ((handle) => window.clearTimeout(handle));
this.target =
options.target ??
(typeof document === 'undefined' ? null : document);
}
/**
* Starts (or restarts) the countdown. `minutes === 0` means "never by
* idle" — only an explicit lock or a restart locks again.
*/
arm(minutes: number): void {
this.disarm();
if (!Number.isFinite(minutes) || minutes <= 0) {
return;
}
this.timeoutMs = minutes * 60_000;
this.lastActivityAt = this.now();
this.listen();
this.scheduleCheck(this.timeoutMs);
}
disarm(): void {
if (this.handle !== null) {
this.cancel(this.handle);
this.handle = null;
}
this.unlisten();
}
get armed(): boolean {
return this.handle !== null;
}
private scheduleCheck(delayMs: number): void {
this.handle = this.schedule(() => {
this.handle = null;
this.check();
}, Math.max(delayMs, 1_000));
}
private check(): void {
const idleFor = this.now() - this.lastActivityAt;
if (idleFor < this.timeoutMs) {
this.scheduleCheck(this.timeoutMs - idleFor);
return;
}
if (this.isBusy()) {
// Playback counts as presence; look again once it may have ended.
this.scheduleCheck(PARENTAL_LOCK_BUSY_RECHECK_MS);
return;
}
this.unlisten();
this.onExpire();
}
private listen(): void {
if (this.listening || !this.target) {
return;
}
this.listening = true;
for (const type of ACTIVITY_EVENTS) {
this.target.addEventListener(type, this.onActivity, {
capture: true,
passive: true,
});
}
}
private unlisten(): void {
if (!this.listening || !this.target) {
return;
}
this.listening = false;
for (const type of ACTIVITY_EVENTS) {
this.target.removeEventListener(type, this.onActivity, {
capture: true,
});
}
}
}
@@ -0,0 +1,32 @@
import { InjectionToken } from '@angular/core';
export type ParentalLockPromptMode = 'unlock' | 'set';
export interface ParentalLockPromptRequest {
/** `unlock` asks for the current PIN, `set` asks for a new one twice. */
mode: ParentalLockPromptMode;
/**
* Unlock only: resolves whether the typed PIN matches. The prompt keeps
* asking on a mismatch and applies its own attempt cooldown.
*/
verify?: (pin: string) => Promise<boolean>;
/** Optional translation key overriding the mode's default title. */
titleKey?: string;
/** Optional translation key overriding the mode's default description. */
descriptionKey?: string;
}
/**
* The UI half of the parental lock: shows a PIN prompt and resolves with the
* accepted PIN, or `null` when the user dismissed it.
*
* Provided by the application (which may depend on UI libraries) so the
* lock service in this data-access library stays free of dialogs.
*/
export interface ParentalLockPrompt {
requestPin(request: ParentalLockPromptRequest): Promise<string | null>;
}
export const PARENTAL_LOCK_PROMPT = new InjectionToken<ParentalLockPrompt>(
'PARENTAL_LOCK_PROMPT'
);
@@ -0,0 +1,78 @@
import { inject, Injectable } from '@angular/core';
import {
normalizeParentalLockStore,
PARENTAL_LOCK_PIN_KEY,
PARENTAL_LOCK_STORE_KEY,
ParentalLockStore,
} from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
/**
* Persistence for the parental lock: the hashed PIN and the lock store.
*
* Both live OUTSIDE `Settings` on purpose. Settings are logged (redacted,
* but still), exported in backups and mirrored to the main process; a PIN
* hash belongs in none of those places. Electron keeps them in the SQLite
* `app_state` table, the PWA in localStorage.
*/
@Injectable({ providedIn: 'root' })
export class ParentalLockStorageService {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly databaseService = inject(DatabaseService);
private get usesAppState(): boolean {
return this.runtime.supportsAppStateStorage;
}
async readPinHash(): Promise<string | null> {
const value = await this.read(PARENTAL_LOCK_PIN_KEY);
return value && value.trim() !== '' ? value : null;
}
async writePinHash(hash: string): Promise<boolean> {
return this.write(PARENTAL_LOCK_PIN_KEY, hash);
}
async readLocks(): Promise<ParentalLockStore> {
const raw = await this.read(PARENTAL_LOCK_STORE_KEY);
if (!raw) {
return {};
}
try {
return normalizeParentalLockStore(JSON.parse(raw));
} catch {
return {};
}
}
async writeLocks(store: ParentalLockStore): Promise<boolean> {
return this.write(
PARENTAL_LOCK_STORE_KEY,
JSON.stringify(normalizeParentalLockStore(store))
);
}
private async read(key: string): Promise<string | null> {
if (this.usesAppState) {
return this.databaseService.getAppState(key);
}
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
private async write(key: string, value: string): Promise<boolean> {
if (this.usesAppState) {
return this.databaseService.setAppState(key, value);
}
try {
localStorage.setItem(key, value);
return localStorage.getItem(key) === value;
} catch {
return false;
}
}
}
@@ -0,0 +1,257 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { webcrypto } from 'node:crypto';
import { hashParentalLockPin } from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { SettingsStore } from '../settings-store.service';
import { PARENTAL_LOCK_PROMPT, ParentalLockPromptRequest } from './parental-lock-prompt.token';
import { ParentalLockStorageService } from './parental-lock-storage';
import { ParentalLockService } from './parental-lock.service';
describe('ParentalLockService', () => {
const originalElectron = window.electron;
let parentalLockEnabled: ReturnType<typeof signal<boolean>>;
let parentalLockRelockMinutes: ReturnType<typeof signal<number>>;
let updateSettings: jest.Mock;
let storage: {
pinHash: string | null;
locks: Record<string, unknown>;
readPinHash: jest.Mock;
writePinHash: jest.Mock;
readLocks: jest.Mock;
writeLocks: jest.Mock;
};
let prompt: { requestPin: jest.Mock };
let setCategoryLocks: jest.Mock;
let setParentalLockState: jest.Mock;
let updateBridgeSettings: jest.Mock;
beforeAll(() => {
if (!globalThis.crypto?.subtle) {
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: webcrypto,
});
}
});
beforeEach(() => {
parentalLockEnabled = signal(false);
parentalLockRelockMinutes = signal(15);
updateSettings = jest.fn(async (patch: Record<string, unknown>) => {
if ('parentalLockEnabled' in patch) {
parentalLockEnabled.set(patch['parentalLockEnabled'] === true);
}
if ('parentalLockRelockMinutes' in patch) {
parentalLockRelockMinutes.set(
patch['parentalLockRelockMinutes'] as number
);
}
});
storage = {
pinHash: null,
locks: {},
readPinHash: jest.fn(async () => storage.pinHash),
writePinHash: jest.fn(async (hash: string) => {
storage.pinHash = hash;
return true;
}),
readLocks: jest.fn(async () => storage.locks),
writeLocks: jest.fn(async (locks: Record<string, unknown>) => {
storage.locks = locks;
return true;
}),
};
prompt = { requestPin: jest.fn() };
setCategoryLocks = jest.fn(async () => true);
setParentalLockState = jest.fn(async () => undefined);
updateBridgeSettings = jest.fn(async () => undefined);
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: {
setParentalLockState,
updateSettings: updateBridgeSettings,
},
});
TestBed.configureTestingModule({
providers: [
ParentalLockService,
{
provide: SettingsStore,
useValue: {
parentalLockEnabled,
parentalLockRelockMinutes,
loadSettings: jest.fn(async () => undefined),
updateSettings,
},
},
{ provide: ParentalLockStorageService, useValue: storage },
{ provide: PARENTAL_LOCK_PROMPT, useValue: prompt },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource: true },
},
{ provide: DatabaseService, useValue: { setCategoryLocks } },
],
});
});
afterEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: originalElectron,
});
});
async function createService(): Promise<ParentalLockService> {
const service = TestBed.inject(ParentalLockService);
await service.initialize();
TestBed.flushEffects();
return service;
}
it('is inactive while the feature is off and reports that to the main process', async () => {
const service = await createService();
expect(service.enabled()).toBe(false);
expect(service.active()).toBe(false);
expect(service.hasPin()).toBe(false);
expect(setParentalLockState).toHaveBeenLastCalledWith(false);
await expect(service.requestUnlock()).resolves.toBe(true);
expect(prompt.requestPin).not.toHaveBeenCalled();
});
it('starts locked with the feature on and unlocks through the prompt', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
prompt.requestPin.mockImplementation(
async (request: ParentalLockPromptRequest) =>
(await request.verify?.('1234')) ? '1234' : null
);
const service = await createService();
expect(service.active()).toBe(true);
expect(setParentalLockState).toHaveBeenLastCalledWith(true);
const unlocked = service.requestUnlock();
const concurrent = service.requestUnlock();
await expect(unlocked).resolves.toBe(true);
await expect(concurrent).resolves.toBe(true);
expect(prompt.requestPin).toHaveBeenCalledTimes(1);
TestBed.flushEffects();
expect(service.unlocked()).toBe(true);
expect(service.active()).toBe(false);
expect(setParentalLockState).toHaveBeenLastCalledWith(false);
service.lock();
TestBed.flushEffects();
expect(service.active()).toBe(true);
expect(setParentalLockState).toHaveBeenLastCalledWith(true);
});
it('stays locked when the prompt is dismissed', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
prompt.requestPin.mockResolvedValue(null);
const service = await createService();
await expect(service.requestUnlock()).resolves.toBe(false);
expect(service.active()).toBe(true);
});
it('setupPin stores a hash, enables the feature and leaves the parent unlocked', async () => {
prompt.requestPin.mockResolvedValue('9876');
const service = await createService();
await expect(service.setupPin()).resolves.toBe(true);
TestBed.flushEffects();
expect(storage.writePinHash).toHaveBeenCalledTimes(1);
expect(storage.pinHash).not.toContain('9876');
expect(updateSettings).toHaveBeenCalledWith({ parentalLockEnabled: true });
expect(updateBridgeSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
expect(service.enabled()).toBe(true);
expect(service.unlocked()).toBe(true);
expect(service.hasPin()).toBe(true);
});
it('disable requires the PIN and keeps the locks', async () => {
storage.pinHash = await hashParentalLockPin('1234');
storage.locks = { 'p-1': { m3u: ['Adult'] } };
parentalLockEnabled.set(true);
prompt.requestPin.mockResolvedValueOnce(null);
const service = await createService();
await expect(service.disable()).resolves.toBe(false);
expect(service.enabled()).toBe(true);
prompt.requestPin.mockResolvedValueOnce('1234');
await expect(service.disable()).resolves.toBe(true);
expect(service.enabled()).toBe(false);
expect(service.lockedGroupTitles('p-1')).toEqual(['Adult']);
});
it('persists locks per portal, stamps the Xtream column and bumps the version', async () => {
const service = await createService();
const versionBefore = service.version();
await expect(
service.setXtreamLocks('p-1', 'live', [3, 3, 9])
).resolves.toBe(true);
await expect(
service.setStalkerLocks('p-2', 'itv', ['12'])
).resolves.toBe(true);
await expect(service.setM3uLocks('p-3', ['XXX'])).resolves.toBe(true);
expect(setCategoryLocks).toHaveBeenCalledWith('p-1', 'live', [3, 9]);
expect(service.lockedXtreamIds('p-1', 'live')).toEqual([3, 9]);
expect(service.lockedXtreamIds('p-1', 'movies')).toEqual([]);
expect(service.lockedStalkerIds('p-2', 'itv')).toEqual(['12']);
expect(service.lockedGroupTitles('p-3')).toEqual(['XXX']);
expect(service.version()).toBeGreaterThan(versionBefore);
expect(storage.writeLocks).toHaveBeenCalledTimes(3);
});
it('answers "locked" only while the lock is active', async () => {
storage.pinHash = await hashParentalLockPin('1234');
storage.locks = {
'p-1': {
xtream: [{ categoryType: 'live', xtreamId: 3 }],
stalker: [{ categoryType: 'vod', categoryId: '7' }],
m3u: ['XXX'],
},
};
parentalLockEnabled.set(true);
prompt.requestPin.mockResolvedValue('1234');
const service = await createService();
expect(service.isXtreamCategoryLocked('p-1', 'live', 3)).toBe(true);
expect(service.isXtreamCategoryLocked('p-1', 'live', 4)).toBe(false);
expect(service.isStalkerCategoryLocked('p-1', 'vod', 7)).toBe(true);
expect(service.isM3uGroupLocked('p-1', 'XXX')).toBe(true);
await service.requestUnlock();
expect(service.isXtreamCategoryLocked('p-1', 'live', 3)).toBe(false);
expect(service.isM3uGroupLocked('p-1', 'XXX')).toBe(false);
});
it('replacePlaylistLocks re-stamps every Xtream type', async () => {
const service = await createService();
await service.replacePlaylistLocks('p-1', {
xtream: [{ categoryType: 'movies', xtreamId: 5 }],
stalker: [],
m3u: [],
});
expect(setCategoryLocks).toHaveBeenCalledWith('p-1', 'live', []);
expect(setCategoryLocks).toHaveBeenCalledWith('p-1', 'movies', [5]);
expect(setCategoryLocks).toHaveBeenCalledWith('p-1', 'series', []);
});
});
@@ -0,0 +1,473 @@
import {
computed,
effect,
inject,
Injectable,
signal,
untracked,
} from '@angular/core';
import {
createEmptyParentalLockPlaylistLocks,
hashParentalLockPin,
isParentalLockPlaylistLocksEmpty,
lockedStalkerCategoryIds,
lockedXtreamCategoryIds,
normalizeParentalLockPlaylistLocks,
normalizeParentalLockRelockMinutes,
ParentalLockPlaylistLocks,
ParentalLockStalkerCategoryType,
ParentalLockStore,
ParentalLockXtreamCategoryType,
verifyParentalLockPin,
} from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { SettingsStore } from '../settings-store.service';
import { ParentalLockIdleTimer } from './parental-lock-idle-timer';
import {
PARENTAL_LOCK_PROMPT,
ParentalLockPromptRequest,
} from './parental-lock-prompt.token';
import { ParentalLockStorageService } from './parental-lock-storage';
const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [
'live',
'movies',
'series',
];
/**
* Parental lock: hides locked categories everywhere until the PIN is
* entered.
*
* Source of truth for WHICH categories are locked is the lock store held
* here (persisted through `ParentalLockStorageService`); the Electron
* `categories.locked` column is only a query index derived from it, so the
* SQLite worker can filter reads server-side.
*
* `active` is the enforcement flag: feature enabled and no PIN entered.
* Everything that renders catalog content asks this service — the Xtream
* data sources, the Stalker store, the M3U channel list, the workspace
* rails — and re-queries when `version` changes. The unlock lives in memory
* only: a restart always locks again, and so does the idle timer.
*/
@Injectable({ providedIn: 'root' })
export class ParentalLockService {
private readonly settingsStore = inject(SettingsStore);
private readonly storage = inject(ParentalLockStorageService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly databaseService = inject(DatabaseService);
private readonly prompt = inject(PARENTAL_LOCK_PROMPT, { optional: true });
private readonly unlockedState = signal(false);
private readonly pinHash = signal<string | null>(null);
private readonly lockStore = signal<ParentalLockStore>({});
private readonly versionState = signal(0);
// The main process starts LOCKED whenever the feature is on (mirrored
// setting). Reporting our state before settings have loaded would send a
// spurious "unlocked" and open a window of unfiltered reads.
private readonly settingsReady = signal(false);
private readonly busyProbes = new Set<() => boolean>();
private readonly idleTimer = new ParentalLockIdleTimer({
onExpire: () => this.lock(),
isBusy: () => [...this.busyProbes].some((probe) => probe()),
});
private initialization: Promise<void> | null = null;
private pendingUnlock: Promise<boolean> | null = null;
/** The feature switch from settings. */
readonly enabled = computed(
() => this.settingsStore.parentalLockEnabled?.() === true
);
/** Feature on and the PIN has been entered this session. */
readonly unlocked = computed(
() => this.enabled() && this.unlockedState()
);
/** Locked categories must currently be withheld. */
readonly active = computed(() => this.enabled() && !this.unlockedState());
/** A PIN exists; enabling is only possible once this is true. */
readonly hasPin = computed(() => this.pinHash() !== null);
/** Bumps whenever `active` or the lock store changes; consumers re-query. */
readonly version = this.versionState.asReadonly();
readonly relockMinutes = computed(() =>
normalizeParentalLockRelockMinutes(
this.settingsStore.parentalLockRelockMinutes?.()
)
);
constructor() {
effect(() => {
const active = this.active();
if (!this.settingsReady()) {
return;
}
untracked(() => {
this.versionState.update((value) => value + 1);
this.syncMainProcess(active);
if (!active) {
this.idleTimer.arm(this.enabled() ? this.relockMinutes() : 0);
} else {
this.idleTimer.disarm();
}
});
});
effect(() => {
const minutes = this.relockMinutes();
untracked(() => {
if (this.unlocked()) {
this.idleTimer.arm(minutes);
}
});
});
}
/** Loads the persisted PIN hash and lock store once. */
initialize(): Promise<void> {
if (!this.initialization) {
this.initialization = (async () => {
const [pinHash, locks] = await Promise.all([
this.storage.readPinHash(),
this.storage.readLocks(),
this.settingsStore.loadSettings(),
]);
this.pinHash.set(pinHash);
this.lockStore.set(locks);
this.versionState.update((value) => value + 1);
this.settingsReady.set(true);
})().catch((error) => {
console.error('Failed to load the parental lock state.', error);
this.settingsReady.set(true);
});
}
return this.initialization;
}
/**
* Something that should keep the app unlocked while true, e.g. a playing
* video. Returns the unregister function.
*/
registerBusyProbe(probe: () => boolean): () => void {
this.busyProbes.add(probe);
return () => this.busyProbes.delete(probe);
}
/**
* Asks for the PIN when the lock is active. Resolves true when the app
* is unlocked afterwards (already unlocked, feature off, or the PIN was
* accepted). Concurrent callers share one prompt.
*/
requestUnlock(
options: Pick<ParentalLockPromptRequest, 'titleKey' | 'descriptionKey'> = {}
): Promise<boolean> {
if (!this.active()) {
return Promise.resolve(true);
}
if (this.pendingUnlock) {
return this.pendingUnlock;
}
this.pendingUnlock = this.promptForUnlock(options).finally(() => {
this.pendingUnlock = null;
});
return this.pendingUnlock;
}
private async promptForUnlock(
options: Pick<ParentalLockPromptRequest, 'titleKey' | 'descriptionKey'>
): Promise<boolean> {
await this.initialize();
const hash = this.pinHash();
if (!this.prompt || !hash) {
return false;
}
const pin = await this.prompt.requestPin({
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
...options,
});
if (pin === null) {
return false;
}
this.unlockedState.set(true);
return true;
}
/** Locks immediately; the next locked surface needs the PIN again. */
lock(): void {
this.unlockedState.set(false);
}
/**
* Sets a new PIN through the prompt and turns the feature on. The
* parent who just typed the PIN stays unlocked. Returns whether it
* happened.
*/
async setupPin(): Promise<boolean> {
await this.initialize();
if (!this.prompt) {
return false;
}
const pin = await this.prompt.requestPin({ mode: 'set' });
if (pin === null) {
return false;
}
if (!(await this.storePin(pin))) {
return false;
}
this.unlockedState.set(true);
if (!this.enabled()) {
await this.settingsStore.updateSettings({
parentalLockEnabled: true,
});
this.mirrorEnabledSetting(true);
}
return true;
}
/** Verifies the current PIN, then replaces it. */
async changePin(): Promise<boolean> {
await this.initialize();
if (!this.prompt || !this.hasPin()) {
return false;
}
if (this.enabled() && !(await this.requestUnlock())) {
return false;
}
const pin = await this.prompt.requestPin({ mode: 'set' });
if (pin === null) {
return false;
}
return this.storePin(pin);
}
/** Turns the feature off after the PIN was entered; locks are kept. */
async disable(): Promise<boolean> {
if (!this.enabled()) {
return true;
}
if (!(await this.requestUnlock())) {
return false;
}
await this.settingsStore.updateSettings({
parentalLockEnabled: false,
});
this.mirrorEnabledSetting(false);
this.unlockedState.set(false);
return true;
}
async setRelockMinutes(minutes: number): Promise<void> {
await this.settingsStore.updateSettings({
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(minutes),
});
}
// -- Lock store -------------------------------------------------------
locksFor(playlistId: string): ParentalLockPlaylistLocks {
return (
this.lockStore()[playlistId] ??
createEmptyParentalLockPlaylistLocks()
);
}
lockedXtreamIds(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType
): number[] {
return lockedXtreamCategoryIds(
this.lockStore()[playlistId],
categoryType
);
}
lockedStalkerIds(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType
): string[] {
return lockedStalkerCategoryIds(
this.lockStore()[playlistId],
categoryType
);
}
lockedGroupTitles(playlistId: string): string[] {
return this.lockStore()[playlistId]?.m3u ?? [];
}
/** Whether the category is locked AND currently withheld. */
isXtreamCategoryLocked(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType,
xtreamId: number
): boolean {
return (
this.active() &&
this.lockedXtreamIds(playlistId, categoryType).includes(xtreamId)
);
}
isStalkerCategoryLocked(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType,
categoryId: string | number | null | undefined
): boolean {
if (categoryId === null || categoryId === undefined) {
return false;
}
return (
this.active() &&
this.lockedStalkerIds(playlistId, categoryType).includes(
String(categoryId)
)
);
}
isM3uGroupLocked(playlistId: string, groupTitle: string): boolean {
return (
this.active() && this.lockedGroupTitles(playlistId).includes(groupTitle)
);
}
async setXtreamLocks(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType,
xtreamIds: number[]
): Promise<boolean> {
const current = this.locksFor(playlistId);
const next: ParentalLockPlaylistLocks = {
...current,
xtream: [
...current.xtream.filter(
(entry) => entry.categoryType !== categoryType
),
...[...new Set(xtreamIds)].map((xtreamId) => ({
categoryType,
xtreamId,
})),
],
};
if (!(await this.persistPlaylistLocks(playlistId, next))) {
return false;
}
return this.stampXtreamLocks(playlistId, [categoryType]);
}
async setStalkerLocks(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType,
categoryIds: string[]
): Promise<boolean> {
const current = this.locksFor(playlistId);
return this.persistPlaylistLocks(playlistId, {
...current,
stalker: [
...current.stalker.filter(
(entry) => entry.categoryType !== categoryType
),
...[...new Set(categoryIds)].map((categoryId) => ({
categoryType,
categoryId,
})),
],
});
}
async setM3uLocks(
playlistId: string,
groupTitles: string[]
): Promise<boolean> {
return this.persistPlaylistLocks(playlistId, {
...this.locksFor(playlistId),
m3u: [...new Set(groupTitles)],
});
}
/** Backup restore: replaces every lock of one playlist. */
async replacePlaylistLocks(
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
if (!(await this.persistPlaylistLocks(playlistId, locks))) {
return false;
}
return this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES);
}
/**
* Re-stamps the Electron `categories.locked` index for a playlist from
* the store, e.g. after a refresh recreated the rows.
*/
async stampXtreamLocks(
playlistId: string,
categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES
): Promise<boolean> {
if (!this.runtime.supportsXtreamSqliteDataSource) {
return true;
}
let success = true;
for (const categoryType of categoryTypes) {
success =
(await this.databaseService.setCategoryLocks(
playlistId,
categoryType,
this.lockedXtreamIds(playlistId, categoryType)
)) && success;
}
return success;
}
private async persistPlaylistLocks(
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
await this.initialize();
const normalized = normalizeParentalLockPlaylistLocks(locks);
const next: ParentalLockStore = { ...this.lockStore() };
if (isParentalLockPlaylistLocksEmpty(normalized)) {
delete next[playlistId];
} else {
next[playlistId] = normalized;
}
if (!(await this.storage.writeLocks(next))) {
return false;
}
this.lockStore.set(next);
this.versionState.update((value) => value + 1);
return true;
}
private async storePin(pin: string): Promise<boolean> {
try {
const hash = await hashParentalLockPin(pin);
if (!(await this.storage.writePinHash(hash))) {
return false;
}
this.pinHash.set(hash);
return true;
} catch (error) {
console.error('Failed to store the parental lock PIN.', error);
return false;
}
}
private syncMainProcess(active: boolean): void {
const bridge = window.electron;
if (typeof bridge?.setParentalLockState !== 'function') {
return;
}
void bridge.setParentalLockState(active).catch((error) => {
console.error('Failed to sync the parental lock state.', error);
});
}
private mirrorEnabledSetting(enabled: boolean): void {
const bridge = window.electron;
if (typeof bridge?.updateSettings !== 'function') {
return;
}
void bridge
.updateSettings({ parentalLockEnabled: enabled })
.catch(() => undefined);
}
}
@@ -56,18 +56,13 @@ export function createPlaylistBackupService(
? 'consumed'
: 'superseded';
}
if (
pendingSnapshot.revision !== expectedSnapshot.revision
) {
if (pendingSnapshot.revision !== expectedSnapshot.revision) {
return 'superseded';
}
await apply(pendingSnapshot.state);
if (
!pendingRestoreService.clear(
playlistId,
pendingSnapshot.state
)
!pendingRestoreService.clear(playlistId, pendingSnapshot.state)
) {
return 'consume-failed';
}
@@ -125,6 +120,10 @@ export function createPlaylistBackupService(
clearForPlaylist: jest.fn().mockResolvedValue(true),
},
pendingRestoreService,
parentalLock: {
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
},
...overrides,
});
+114 -16
View File
@@ -6,6 +6,7 @@ import { DatabaseService } from './database-electron.service';
import { VodSourcePinService } from './vod-source-pin.service';
import { PlaybackPositionService } from './playback-position.service';
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
import { ParentalLockService } from './parental-lock/parental-lock.service';
import {
isM3uRecentlyViewedItem,
normalizeXtreamPendingRestoreState,
@@ -25,6 +26,11 @@ import {
XtreamBackupSourcePin,
XtreamPendingRestoreState,
createRandomId,
normalizeParentalLockStalkerCategories,
normalizeParentalLockXtreamCategories,
ParentalLockPlaylistLocks,
StalkerBackupLockedCategory,
XtreamBackupHiddenCategory,
} from '@iptvnator/shared/interfaces';
export interface PlaylistBackupExportPayload {
@@ -65,6 +71,7 @@ export class PlaylistBackupService {
private readonly pendingRestoreService = inject(
XtreamPendingRestoreService
);
private readonly parentalLock = inject(ParentalLockService);
private backupImportTail?: Promise<void>;
async exportBackup(): Promise<PlaylistBackupExportPayload> {
@@ -96,9 +103,9 @@ export class PlaylistBackupService {
}
importBackup(json: string): Promise<PlaylistBackupImportSummary> {
const importResult = (
this.backupImportTail ?? Promise.resolve()
).then(() => this.executeImportBackup(json));
const importResult = (this.backupImportTail ?? Promise.resolve()).then(
() => this.executeImportBackup(json)
);
this.backupImportTail = importResult.then(
() => undefined,
() => undefined
@@ -163,6 +170,8 @@ export class PlaylistBackupService {
this.playlistsService.addPlaylist(nextPlaylist)
);
await this.restoreParentalLocks(targetId, entry);
if (entry.portalType === 'xtream') {
await this.restoreXtreamEntry(targetId, entry);
}
@@ -243,6 +252,7 @@ export class PlaylistBackupService {
? playlist.hiddenGroupTitles
: []
),
...this.optionalLockedGroupTitles(playlist._id),
},
};
}
@@ -269,6 +279,7 @@ export class PlaylistBackupService {
favorites: [],
recentlyViewed: [],
playbackPositions: [],
...this.optionalLockedXtreamCategories(playlist._id),
// NOT `[]`. Pins are Electron-only, so out here we cannot
// read them — which is not the same as knowing there are
// none. Restore treats the collection as authoritative and
@@ -321,6 +332,7 @@ export class PlaylistBackupService {
...this.mapHiddenCategories(movieCategories, 'movies'),
...this.mapHiddenCategories(seriesCategories, 'series'),
],
...this.optionalLockedXtreamCategories(playlist._id),
favorites: favorites.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamContentType,
@@ -416,6 +428,7 @@ export class PlaylistBackupService {
: {}),
},
userState: {
...this.optionalLockedStalkerCategories(playlist._id),
favorites: this.extractStalkerItems(playlist.favorites),
recentlyViewed: this.extractStalkerItems(
playlist.recentlyViewed
@@ -529,6 +542,15 @@ export class PlaylistBackupService {
);
}
if (
entry.userState.lockedCategories !== undefined &&
!Array.isArray(entry.userState.lockedCategories)
) {
throw new PlaylistBackupError(
`Xtream backup "${entry.title}" has invalid parental locks.`
);
}
// Absent in archives written before multi-source existed, so
// its absence is not damage — only a wrong type is.
if (
@@ -706,11 +728,12 @@ export class PlaylistBackupService {
playlistId: string,
existing: Playlist | null
): Promise<Playlist> {
const parsedPlaylist = await this.playlistsService.handlePlaylistParsing(
'TEXT',
entry.source.rawM3u,
entry.title
);
const parsedPlaylist =
await this.playlistsService.handlePlaylistParsing(
'TEXT',
entry.source.rawM3u,
entry.title
);
const now = new Date().toISOString();
return {
@@ -829,7 +852,6 @@ export class PlaylistBackupService {
});
}
private async restoreXtreamEntry(
playlistId: string,
entry: XtreamPlaylistBackupEntry
@@ -859,13 +881,12 @@ export class PlaylistBackupService {
return;
}
const restoreResult =
await this.pendingRestoreService.applyAndConsume(
playlistId,
restoreSnapshot,
(pendingState) =>
this.applyXtreamRestoreState(playlistId, pendingState)
);
const restoreResult = await this.pendingRestoreService.applyAndConsume(
playlistId,
restoreSnapshot,
(pendingState) =>
this.applyXtreamRestoreState(playlistId, pendingState)
);
if (restoreResult !== 'consumed') {
throw new PlaylistBackupError(
`Clearing pending restore state for "${playlistId}" failed.`
@@ -1011,6 +1032,83 @@ export class PlaylistBackupService {
}
}
// Parental locks are written only when the playlist has some: an archive
// without the field says nothing about locks (older builds wrote none),
// so an unlocked playlist stays byte-identical to a pre-lock export.
private optionalLockedXtreamCategories(playlistId: string): {
lockedCategories?: XtreamBackupHiddenCategory[];
} {
const locked = this.parentalLock
.locksFor(playlistId)
.xtream.map((entry) => ({
categoryType: entry.categoryType,
xtreamId: entry.xtreamId,
}));
return locked.length > 0 ? { lockedCategories: locked } : {};
}
private optionalLockedStalkerCategories(playlistId: string): {
lockedCategories?: StalkerBackupLockedCategory[];
} {
const locked = this.parentalLock
.locksFor(playlistId)
.stalker.map((entry) => ({ ...entry }));
return locked.length > 0 ? { lockedCategories: locked } : {};
}
private optionalLockedGroupTitles(playlistId: string): {
lockedGroupTitles?: string[];
} {
const locked = this.uniqueStrings(
this.parentalLock.locksFor(playlistId).m3u
);
return locked.length > 0 ? { lockedGroupTitles: locked } : {};
}
/**
* Parental locks travel next to the hidden state of each entry. Absent
* fields mean the archive predates the lock (or was written by a build
* that could not read it) and leave the playlist's locks alone.
*/
private async restoreParentalLocks(
playlistId: string,
entry: PlaylistBackupEntry
): Promise<void> {
const current = this.parentalLock.locksFor(playlistId);
let next: ParentalLockPlaylistLocks | null = null;
if (entry.portalType === 'm3u') {
const titles = entry.userState.lockedGroupTitles;
if (Array.isArray(titles)) {
next = { ...current, m3u: this.uniqueStrings(titles) };
}
} else if (entry.portalType === 'xtream') {
const categories = entry.userState.lockedCategories;
if (Array.isArray(categories)) {
next = {
...current,
xtream: normalizeParentalLockXtreamCategories(categories),
};
}
} else if (Array.isArray(entry.userState.lockedCategories)) {
next = {
...current,
stalker: normalizeParentalLockStalkerCategories(
entry.userState.lockedCategories
),
};
}
if (
next &&
!(await this.parentalLock.replacePlaylistLocks(playlistId, next))
) {
throw new PlaylistBackupError(
`Restoring the parental locks for "${playlistId}" failed.`
);
}
}
private mapHiddenCategories(
categories: Array<{
hidden: boolean;
@@ -0,0 +1,113 @@
import {
DEFAULT_DASHBOARD_RAILS_SETTINGS,
DEFAULT_PARENTAL_LOCK_RELOCK_MINUTES,
DEFAULT_TMDB_SETTINGS,
Language,
Settings,
StartupBehavior,
StreamFormat,
Theme,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
/** Defaults and boot-time helpers of `SettingsStore`, split out for size. */
export const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: true,
playerAmbientMode: false,
playerUpNextRail: true,
fullscreenChannelPanel: true,
vodAutoFailover: false,
m3uVodDetails: true,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal',
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
vlcPlayerArguments: '',
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: 8765,
epgUrl: [],
downloadFolder: '',
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: '',
embeddedMpvAutoReconnect: true,
portalConnectivityGuard: true,
coverSize: 'medium',
epgViewMode: 'timeline',
epgOffsetMinutes: 0,
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
preferUploadedEpgOverXtream: false,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
tmdb: DEFAULT_TMDB_SETTINGS,
parentalLockEnabled: false,
parentalLockRelockMinutes: DEFAULT_PARENTAL_LOCK_RELOCK_MINUTES,
};
/**
* Which half of the settings persistence round-trip failed, if any.
*
* Settings live in the renderer's IndexedDB, which can be unavailable for
* reasons the app cannot control (a second instance holding the Chromium
* storage lock, a corrupted profile, storage blocked by security software).
* Both failures used to be swallowed: `updateSettings` patches the in-memory
* state before persisting, so a failed write still looked applied until the
* next restart (issue #1156). Recording the failure lets the settings UI say
* so instead of pretending the change stuck.
*/
export type SettingsStorageFailure = 'load' | 'save';
export interface SettingsStorageState {
storageFailure: SettingsStorageFailure | null;
}
let embeddedMpvPrepareScheduled = false;
export function scheduleEmbeddedMpvPrepare(): void {
if (
embeddedMpvPrepareScheduled ||
typeof window === 'undefined' ||
!window.electron?.prepareEmbeddedMpv
) {
return;
}
embeddedMpvPrepareScheduled = true;
const prepare = () => {
void window.electron
.prepareEmbeddedMpv?.()
.then((support) => {
if (!support?.supported) {
embeddedMpvPrepareScheduled = false;
}
})
.catch((error) => {
embeddedMpvPrepareScheduled = false;
console.warn('Failed to prepare embedded MPV.', error);
});
};
const idleWindow = window as typeof window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
if (idleWindow.requestIdleCallback) {
idleWindow.requestIdleCallback(prepare, { timeout: 5000 });
} else {
window.setTimeout(prepare, 2000);
}
}
+33 -97
View File
@@ -27,107 +27,18 @@ import {
VideoPlayer,
normalizeEpgOffsetMinutes,
normalizeDashboardRailsSettings,
normalizeParentalLockRelockMinutes,
normalizeStartupWindowMode,
DEFAULT_PARENTAL_LOCK_RELOCK_MINUTES,
} from '@iptvnator/shared/interfaces';
const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: true,
playerAmbientMode: false,
playerUpNextRail: true,
fullscreenChannelPanel: true,
vodAutoFailover: false,
m3uVodDetails: true,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal',
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
vlcPlayerArguments: '',
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: 8765,
epgUrl: [],
downloadFolder: '',
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: '',
embeddedMpvAutoReconnect: true,
portalConnectivityGuard: true,
coverSize: 'medium',
epgViewMode: 'timeline',
epgOffsetMinutes: 0,
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
preferUploadedEpgOverXtream: false,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
tmdb: DEFAULT_TMDB_SETTINGS,
};
import {
DEFAULT_SETTINGS,
scheduleEmbeddedMpvPrepare,
SettingsStorageState,
} from './settings-store.defaults';
/**
* Which half of the settings persistence round-trip failed, if any.
*
* Settings live in the renderer's IndexedDB, which can be unavailable for
* reasons the app cannot control (a second instance holding the Chromium
* storage lock, a corrupted profile, storage blocked by security software).
* Both failures used to be swallowed: `updateSettings` patches the in-memory
* state before persisting, so a failed write still looked applied until the
* next restart (issue #1156). Recording the failure lets the settings UI say
* so instead of pretending the change stuck.
*/
export type SettingsStorageFailure = 'load' | 'save';
interface SettingsStorageState {
storageFailure: SettingsStorageFailure | null;
}
let embeddedMpvPrepareScheduled = false;
function scheduleEmbeddedMpvPrepare(): void {
if (
embeddedMpvPrepareScheduled ||
typeof window === 'undefined' ||
!window.electron?.prepareEmbeddedMpv
) {
return;
}
embeddedMpvPrepareScheduled = true;
const prepare = () => {
void window.electron
.prepareEmbeddedMpv?.()
.then((support) => {
if (!support?.supported) {
embeddedMpvPrepareScheduled = false;
}
})
.catch((error) => {
embeddedMpvPrepareScheduled = false;
console.warn('Failed to prepare embedded MPV.', error);
});
};
const idleWindow = window as typeof window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
if (idleWindow.requestIdleCallback) {
idleWindow.requestIdleCallback(prepare, { timeout: 5000 });
} else {
window.setTimeout(prepare, 2000);
}
}
export type { SettingsStorageFailure } from './settings-store.defaults';
export const SettingsStore = signalStore(
{ providedIn: 'root' },
@@ -185,6 +96,12 @@ export const SettingsStore = signalStore(
dashboardRails: normalizeDashboardRailsSettings(
storedSettings.dashboardRails
),
parentalLockEnabled:
storedSettings.parentalLockEnabled === true,
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(
storedSettings.parentalLockRelockMinutes
),
});
void this.sanitizeEmbeddedMpvSelection().catch(
(error) => {
@@ -254,6 +171,20 @@ export const SettingsStore = signalStore(
),
}
: {}),
...(settings.parentalLockEnabled !== undefined
? {
parentalLockEnabled:
settings.parentalLockEnabled === true,
}
: {}),
...(settings.parentalLockRelockMinutes !== undefined
? {
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(
settings.parentalLockRelockMinutes
),
}
: {}),
});
// Save the complete settings object, not just the partial update
const completeSettings = this.getSettings();
@@ -362,6 +293,11 @@ export const SettingsStore = signalStore(
store.trustedInsecureTlsHosts?.() ??
DEFAULT_SETTINGS.trustedInsecureTlsHosts,
tmdb: store.tmdb?.() ?? DEFAULT_SETTINGS.tmdb,
parentalLockEnabled: store.parentalLockEnabled?.() === true,
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(
store.parentalLockRelockMinutes?.()
),
};
},
@@ -170,6 +170,7 @@ const CREATE_TABLE_STATEMENTS = [
type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')),
xtream_id INTEGER NOT NULL,
hidden INTEGER DEFAULT 0,
locked INTEGER DEFAULT 0,
UNIQUE(playlist_id, type, xtream_id),
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`,
@@ -377,6 +378,8 @@ const CREATE_TABLE_STATEMENTS = [
const COLUMN_MIGRATION_STATEMENTS = [
// v1.0.0 -> v1.1.0: Add hidden column to categories for category management
`ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0`,
// Parental lock: per-category lock index (issue #285)
`ALTER TABLE categories ADD COLUMN locked INTEGER DEFAULT 0`,
// v1.1.0 -> v1.2.0: Add playlist metadata/payload columns for M3U + unified playlist persistence
`ALTER TABLE playlists ADD COLUMN portal_url TEXT`,
`ALTER TABLE playlists ADD COLUMN count INTEGER`,
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more