mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
* feat(settings): add PIN-protected parental lock for categories (#285) Locks are per category (Xtream category ids, Stalker genre ids, M3U group titles) and kept in one renderer lock store persisted to app_state / localStorage; `categories.locked` is the SQLite index re-stamped from it. While the lock is active the DB worker filters every content read, the PWA data source, the Stalker store and the M3U channel list filter in memory, and the enforcement service reloads the stores and steps off withheld selections. Settings → Parental lock sets the PIN (PBKDF2, never in Settings), the relock timeout and Lock now; lock toggles live in the Xtream/M3U management dialogs and a new Stalker lock dialog, all behind the PIN. Backups carry the locks per playlist entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): harden the parental lock after review - The M3U group dialog opens only after the PIN, like the Xtream and Stalker dialogs: it lists locked group names and can rewrite the locks. - Change PIN and Disable always verify the stored hash, even while the session is unlocked, so an app left unlocked cannot lose its lock. - Stalker paging judges progress on the raw portal page: withheld ids the list has not seen count as progress, a page made only of locked rows requests the next one itself, and the VOD total is reduced by withheld ids so the grid stops asking once every visible row is in. - Parental lock contract linked from the agent context map after the guidance reorganization; bridge helpers split out to stay under the file-size cap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): guard locked categories on routes, PWA search and paging - Xtream and Stalker `:categoryId` routes carry a parental-lock guard: a locked category reached by URL prompts for the PIN and redirects to the section root on refusal (Electron row ids are mapped to provider ids). - PWA search filters withheld categories like the catalog reads. - Electron warm-cache detection confirms an empty, lock-filtered read with the unfiltered existence check instead of refetching from the provider. - A Stalker lock flip past page 1 drops withheld rows at once and restarts the list from page 1 instead of appending onto stale pages. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): compile the PIN hashing helper in the Node backend build The web backend compiles the shared interfaces library without DOM typings, so the DOM-only `SubtleCrypto` / `BufferSource` names broke its Docker build. The helper now describes the WebCrypto surface it needs structurally and reaches it through `globalThis`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): close the remaining parental-lock gaps from review - Detail routes check the item's own category: a locked movie or series paired with an unlocked category id in the URL is still refused. - `requestUnlock()` awaits the settings load before it can answer "not active", so a slow startup cannot open a management dialog unguarded. - `SETTINGS_UPDATE` only persists the `parentalLockEnabled` mirror and releases the worker on switch-off; it no longer re-locks the worker on every ordinary settings save under a renderer that shows "unlocked". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): cover PWA cold navigation, Stalker search and the PWA lock editor - The Xtream detail guard hydrates the PWA session cache before judging an item on a cold navigation and fails closed when the catalog cannot place the item. - The dedicated Stalker search route filters withheld genres, re-fires on lock changes, judges paging on the raw page and restarts from page 1 on a lock flip. - The Xtream category dialog loads its lock candidates through the capability-selected data source; the PWA source now lists its raw categories with lock flags, so locks can be configured there too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): arm the relock timer on enable and harden Stalker search relock - The idle timer follows the unlocked transition instead of `active`, so the session that just enabled the lock still locks itself later. - Stalker search closes an open detail whose genre became withheld on relock and advances by itself past pages made only of locked rows (only while they add ids the list has not seen). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): close lock editors on relock and clear withheld details opened from All The Xtream, Stalker and M3U category editors are gated by the PIN only when they open; an idle relock left them on screen listing locked names with a lock-rewriting Save. Each now closes itself when the session relocks. ParentalLockEnforcementService also judges the selected Xtream/Stalker item by its own category: a detail opened from All, recently added or search has no selected category to vanish with, so it stayed open after a relock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): fail closed on unreadable settings and finish relock clean-up - Unreadable settings (IndexedDB load failure) left the feature switch at its default and announced "unlocked" to the main process. A stored PIN now stands in for the switch, and without one nothing is announced, so the worker keeps its mirrored locked default. - Lock applies run one at a time and abandon superseded results; the Electron data source keys its in-flight share by lock version so a relock can never reuse an unlock refresh's unfiltered rows. - The stored in-portal Xtream search is re-run on a lock change. - Stalker live/radio selections are judged by tv_genre_id, and both live layouts drop the playback of a channel whose category became withheld. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): fail closed on an unreadable lock store and make lock writes reliable - A lock store that cannot be read is no longer treated as empty: while the lock is active every category is withheld (renderer predicates and set-based filters alike) until the PIN is entered or the store reads again, and writes are refused meanwhile so an empty in-memory store can never wipe the persisted locks. The lock set now lives in its own ParentalLockLockStore service. - The M3U group dialog's lock write is awaited and a failed save is reported in a snackbar instead of being silently dropped. - The Electron categories.locked re-stamp clears and re-locks inside one transaction, so a failed restamp keeps the previous index. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): drop pre-relock Stalker search pages and fail closed on a corrupt lock store - A Stalker search page issued before a relock was filtered with the pre-relock withheld set and could still be applied after it; the staleness check now includes the parental lock version. - A lock store payload that does not parse or is not an object is a failed read (everything withheld until it reads again), no longer an empty store. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): close the startup, re-stamp, relock-refresh and switch-persistence gaps - The window before the initial lock store read settles now withholds everything, like an unreadable store: settings can report the feature as on before the locks are known. - The store commits before the SQLite index re-stamp; a failed re-stamp now rolls the store back, a failed rollback re-stamps on the next access, and every launch re-derives the index from the store. - Xtream category/content reloads fail closed: a rejected reload empties the affected lists (content types drop back to idle) instead of keeping rows read under the previous lock state. - Enabling/disabling the feature persists through one guarded path that undoes the in-memory switch and skips the Electron mirror on a failed settings write. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(xtream): move the parental-lock reload specs beside the content spec The content feature spec sits at the 1200-line spec cap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): await the startup lock-index reconciliation and withhold genre-less rows when failing closed - The lock store is readable only once the SQLite index has been re-derived from it, and a re-stamp that keeps failing keeps the session fail-closed, so catalog reads can never serve rows stamped unlocked by a stale index. - While everything is withheld, Stalker rows without a genre are withheld as well (the store filter and the renderer predicate). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): await the enablement mirror, restore partial lock stamps and validate nested lock-store entries - The Electron mirror of the feature switch is awaited; a mirror that cannot be written undoes the settings write, so a reload never starts from a mirror that disagrees with the persisted switch. - A failed multi-type re-stamp rolls the store back AND re-stamps every touched type from it, since earlier types may already carry the new locks; a failed rollback keeps the playlist stale (fail-closed). - A persisted lock store whose nested entries are not what writeLocks produces is a failed read, not an empty store. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): withhold the Xtream catalog at relock time, keep exact M3U titles in backups, roll back a failed relock-timeout save - A relock now fails closed immediately: the selected detail is stepped off against the lock store, the catalog lists and stored search results are emptied, and the filtered reloads publish only while the captured lock version is still current. - Backups carry M3U lock titles verbatim (exact dedup), since the locks match group titles exactly. - A relock-timeout write that fails reverts the in-memory value and shows the settings save-failure snackbar. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): clear the lock index before a playlist's last lock leaves the store, retry failed PIN reads, guard backups on the lock store - A write that removes a playlist's last lock clears the SQLite index first and drops the store key afterwards, so an interruption between the two can only leave a state the startup reconcile repairs toward locked. - A PIN hash read failure is distinct from an absent PIN: the session stays locked and every PIN-protected step re-reads it first. - Backup export awaits parental lock initialization and refuses to run while the lock store is not readable, since an absent lock field means "no opinion" on restore. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): withhold Electron Xtream reads while locks are unknown, persist the switch when settings are unreadable, re-stamp after a recovered read - ElectronXtreamDataSource serves no categories, content or search hits while the lock store withholds everything; its SQLite index may still carry a stale stamp. - setupPin decides whether to persist the switch from the settings value before the PIN is stored, since enabled follows hasPin while the switch is unknown. - A lock store recovered by a later read marks its playlists stale so the index is re-derived, a persisted entry must carry all three lists, and a stale Stalker search page is dropped before touching the withheld-id bookkeeping. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): keep unlocked category routes reachable and defer a relock reload that overtakes the initial hydration - The Xtream category guard no longer runs the item check on category-only routes (Number(null) is 0), which prompted for the PIN on every unlocked VOD and series category while the lock was active. - A lock change during the initial Xtream hydration withholds the rows the hydration publishes and runs the filtered reload once it has settled, on every path that marks the content initialized. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): resolve hidden live categories before relocking playback and reload categories in the deferred hydration path - The Xtream live layout resolves a playing channel's category through the unfiltered rows when the visible list lacks it (search can play a hidden category's channel); until that lookup lands the category is unknown and a relock stops the channel. - A relock that overtakes the initial hydration now withholds the category publications too and reloads categories with the content. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): step off the M3U channel and Stalker selection before awaiting the Xtream relock reload The Xtream store stays populated after leaving that portal, so its reload runs on every apply; a locked M3U channel no longer keeps playing behind a slow database or provider read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): gate the workspace on parental lock init, edit only a readable lock store, guard the deferred reload, validate backup lock entries - The workspace route resolver awaits ParentalLockService.initialize() next to the settings load, so no route or catalog activates before the PIN and lock store are known. - Every lock write re-reads a failed store before building its edit, so a recovered store is edited rather than overwritten. - The deferred hydration reload runs under the publish guard of the request that deferred it. - Backup import validates every parental lock entry and rejects a damaged list instead of erasing the persisted locks on restore. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): discard stale hidden-category lookups and key withheld Stalker rows by their real identity - A hidden-category lookup that lands after a later playback (same provider id, another playlist) no longer overwrites the newer channel's category; resolutions are generation- and playlist-checked. - Withheld Stalker rows are keyed by id, stream_id, movie_id, series_id or the row's cmd/name, so id-less rows no longer collapse onto one key and stall paging past locked pages. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): gate the Electron cached category/content reads while locks are unknown The warm-route hydration reads the cache directly; it now returns nothing while the lock store withholds everything, like the live reads. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): retire in-flight searches on relock clearing and publish lock revisions after the stamps - clearSearchResults() advances the search request version, so a search issued under the previous lock state cannot republish what a relock just cleared. - A lock write publishes its store revision only once every touched type is stamped, so a reload triggered by it cannot read a later type through its old stamps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): retire a resolving Stalker live playback when the session relocks The embedded player defers selecting the channel until its stream resolves, so the enforcement service's cleared selection could not retire the request; it now carries the lock version it was issued under and is dropped when a relock happened meanwhile. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(settings): one lock entry point per rail plus a right-click Lock/Unlock - Stalker's dedicated lock button becomes the same "Manage categories" (tune) button the Xtream rail has; it opens the lock-only dialog, so every portal type shares one entry point and the rail header keeps three actions. - Right-clicking a category (Xtream, Stalker) or an M3U group offers a single-row Lock / Unlock through the shared CategoryLockMenuComponent, behind the same PIN gate and lock store as the dialog. - The settings hint explains where locks are set; group lock strings added to all locales (ru/de translated). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): serialize lock-store writes and drop a deleted playlist's locks - Lock-store mutations run through one write queue: each rewrites the whole persisted store, so overlapping edits could otherwise snapshot the same store and the later write would drop the earlier edit. - Deleting a playlist removes its locks through the PLAYLIST_DELETE_CLEANUP hook; "Remove all playlists" clears the lock store once the deletion has succeeded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): apply single-row lock toggles inside the lock store's write queue The right-click Lock/Unlock (portal categories and M3U groups) built the new list before entering the queue, so two quick toggles shared one snapshot and the second dropped the first. Lock writes now accept an edit of the current list, evaluated inside the queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): retry a failed settings read before any parental-lock settings write updateSettings writes the whole settings object, which after a failed startup read is the defaults; enabling the lock or changing the relock timeout then replaced the user's persisted preferences. The read is retried first and the write refused while settings stay unreadable. The settings writes move to parental-lock-settings-writer.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): show the locked-groups row on the M3U rail and roll the relock timeout back to the recovered value - The M3U groups rail now renders the same "N locked · Enter PIN to show" row as the portal category rail, so locked groups no longer vanish without an in-context unlock. - A failed relock-timeout write rolls back to the value read after the settings retry, not to the pre-retry default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): retry a failed clear-all of the lock store and keep restored new playlists free of stale locks A lock-store clear that failed after "Remove all playlists" only logged, so a later restore reusing a playlist id could inherit the deleted playlist's locks. The in-memory store now empties at once and the persisted clear is retried on the next access; a restore that creates a playlist starts it from empty locks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): count radio playback as lock activity and read the lock store before a restore's stale-id check - The idle relock no longer interrupts a playing radio station: playing <audio> counts as activity, like video. - A restore retries a failed lock-store read before checking a reused id for stale locks, and aborts while the store stays unreadable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): drop withheld Stalker search rows at relock time and keep the M3U unlock row when every group is locked - A relock during a page-1 Stalker search now filters the rows already on screen at once, so old unlocked results are not clickable while the replacement page is pending. - When every M3U group is locked the groups rail still renders, with its "N locked · Enter PIN to show" row, instead of the plain empty state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(settings): keep the PIN dialog and the Stalker enforcement step off the initial path Master (#1712) moved the UI component barrel and the Stalker data layer out of main.js and tightened the initial budget to 2 MB. The parental-lock prompt imported the PIN dialog through the ui/components barrel and the enforcement service injected the Stalker store at startup, which pulled both back in (2.55 MB, over budget). The PIN dialog now loads through a local lazy file on the first prompt, and the Stalker step loads only while a Stalker route is open: initial total 1.65 MB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): restore the lock index when an emptying write fails and publish rollbacks after re-stamping - Removing a playlist's last lock clears the SQLite index first; if the clear or the store write then fails, the index is re-stamped from the previous locks at once. Title matching and multi-source discovery query the worker directly and trust the index, so the stale flag alone did not protect them. - A rollback publishes its store revision only after every type is re-stamped, so a reload cannot read a later type through the attempted stamps. - docs: restore the index rules the earlier surfaces rewrite dropped from the contract, now in the Lock store lifetime section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): keep the M3U groups view when every group is locked With every group locked the filtered channel list is empty, so the container showed its generic empty state and the groups rail's "N locked · Enter PIN to show" row never appeared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): fail closed when the lazy Stalker enforcement step cannot load A rejected chunk (e.g. a stale PWA page after a deployment) escaped applyStalker(), so a locked Stalker selection kept playing after a relock and the Xtream step was skipped. The step now leaves the Stalker route on a load failure, which clears the selection and stops playback, and the Xtream step still runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): defer a stale Xtream hydration as soon as the catalog is withheld On Electron a relock that overtook the initial content hydration waited for the category reload before the content reload set the deferral flag; the older unlocked hydration could publish its streams in that window. withholdCatalog() now sets the flag itself, before anything is awaited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): restore backup locks after the Xtream merge and snapshot the Stalker lock dialog's categories - A backup restore now writes the parental locks last, so a failed Xtream merge leaves the playlist's previous locks in place instead of the backup's possibly smaller set. - The Stalker lock dialog snapshots the category list before its lazy import and opens only if the route is unchanged, so another portal's categories can never be saved under this playlist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): fail closed on relock ahead of the apply queue and judge Xtream selections by the lock store - On relock the synchronous fail-closed steps (M3U channel, Stalker selection, the locked Xtream detail, catalog lists, stored search) run immediately instead of queueing behind an earlier apply that may still wait on a slow or hung read. - The post-reload Xtream checks decide by the lock store through the unfiltered category rows rather than by absence from the reloaded list, which also omits merely hidden categories; unreadable rows fail closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): require the PIN for lock-bearing backup restores and fail closed during index re-stamps - A backup carrying lock lists replaces the matching playlists' locks, possibly with an emptier set; the import now asks for the PIN (after the file was chosen) and aborts when it is refused. - While a write re-stamps the SQLite index the playlist counts as stale, so a relock inside that window reloads fail-closed instead of through the old stamps. The internal store write now needs only a readable store, so a rollback can still land. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): drop parental-lock Xtream reloads once the playlist is switched A reload issued for playlist A no longer publishes into the shared Xtream store after the user opened playlist B: the store's reloads guard on the playlist they read for, and the enforcement apply retires its search refresh and selection checks on a playlist switch as on a newer lock version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): re-ask the PIN before a relocked backup merge and keep the PIN cooldown across prompts A backup merge now asks for the PIN again right before it replaces a playlist's locks when the app relocked during the import, instead of relying on the answer given at the start. The wrong-PIN count and the 30-second pause move from the dialog into the lock service, so dismissing and reopening the prompt no longer resets them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): refuse lock removals that commit after a relock and keep the index stale until the store write lands Lock edits that take a lock away now commit only while the session is unlocked, checked inside the write queue at commit time, so an editor save still in flight (or queued) when the app relocks cannot remove locks. Adding locks stays allowed. The Xtream category dialog drops its lock draft after a relock, and a backup restore re-asks the PIN only when it would remove a lock. Clearing a playlist's last lock keeps its index stale until the store write has landed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): clear an Xtream detail from a hidden category synchronously on relock The synchronous relock step now clears a selected Xtream item whose category the visible category list cannot place (a manually hidden category opened through search), instead of leaving it usable until the awaited reloads and lookup finish. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): fail closed when the Electron bridge lacks the parental lock worker filter A new runtime capability requires the lock-state and index-stamping IPC. When Electron reads Xtream through the SQLite worker without it (a partial or older preload), the locked session withholds every category instead of trusting a worker that never learned the lock state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): re-check lock removals at their durable commit points A lock removal that passed the unlocked check before its write is asked again right after the store write and, for Xtream, after the index stamps. A relock in between writes the previous store back or rolls the stamps back before anything is published. The stale-index bookkeeping, index stamping and store merge move into helpers to keep the lock store within the file size limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): retry a failed revert of a refused lock removal and fail closed meanwhile When writing the previous store back after a relock-refused removal fails, the lock store now keeps a pending rewrite, is not readable (the locked session withholds everything) and rewrites the persisted store from memory on the next access, so a restart cannot load the removal. A failed "Remove all playlists" clear shares the same retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): authorize lock removals when issued and close genre-less Stalker details in fail-closed mode A lock removal is now authorized right before its first write is issued; a relock that lands after that is ordered after the write, which completes. This drops the post-write rollback, whose own failure could leave the persisted store diverged from memory across a restart. The Stalker search closes a detail without a genre on relock while every category is withheld. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): restore the previous locks when an Xtream rollback write fails When an Xtream lock edit's re-stamp fails and the rollback store write fails too, memory now goes back to the previous locks and a pending rewrite persists them on the next store access before the index is re-stamped, so the failed edit cannot take effect through that re-stamp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(stalker): cover page-one rows leaving the screen on relock while the reload hangs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): offer the portal unlock row in fail-closed mode When the lock store cannot be read every portal category is withheld but no locked ids are known, so the rail showed no "Enter PIN to show" row. It now shows the row without a count in that state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): fail closed for direct worker title lookups and capture the Stalker lock dialog context before the PIN Catalog title matching and multi-source discovery query the SQLite worker directly; they now return nothing while the parental lock withholds everything (unreadable store or a bridge without the worker filter). The Stalker lock dialog captures its playlist, provider and section before the PIN prompt and re-checks them after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): retire multi-source alternatives on a lock change and keep reconcile off in-flight stamps The VOD multi-source host keys its discovery session to the parental lock version: a lock change drops the discovered sources, retires discoveries and switches in flight, and rediscovers through the worker's new lock state. Stale-index entries of a write still stamping are no longer retried by a concurrent reconcile, which could re-stamp from a store the write had not committed yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): fail closed on a rejected worker lock sync, tear down Stalker synchronously and capture the Xtream dialog context before the PIN - A rejected lock-state sync to the SQLite worker makes the locked session withhold everything until a later sync succeeds. - The Stalker enforcement chunk is preloaded when a Stalker route opens; a relock runs it synchronously, or leaves the route at once while it is not loaded, instead of awaiting the chunk. - Xtream "Manage categories" captures playlist, provider and section before the PIN prompt and re-checks them after it and after the dialog import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): keep the relock timeout behind the PIN and bind M3U group lock toggles to their playlist A locked session can no longer change the relock timeout: the Settings selector is disabled until the PIN is entered and the service refuses the change while locked. An M3U right-click lock toggle now captures its playlist before the PIN prompt and is saved only if that playlist is still open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): reset a locked M3U channel however it becomes active The enforcement service now checks the active M3U channel whenever it changes while locked, so numeric zapping, next/previous and remote commands, which select from the full channel list, cannot start a channel of a locked group. Numeric zapping also skips such a channel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): bind the M3U group management result to its playlist The groups view captures the playlist before the PIN prompt and drops the management dialog's hidden and locked group lists once another playlist is open, so they cannot be saved under that playlist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(parental-lock): record the accepted restart case of a failed rollback write Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): build bulk lock drafts only from a readable lock store The Xtream and M3U management dialogs offer lock toggles, and the Stalker lock dialog opens, only once the lock store has been read. A draft built from the empty fail-closed snapshot would otherwise replace the real locks with nothing on Save if storage recovered in between. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): keep the parental lock switch on the saved state and roll back to the recovered value The Settings switch snaps back to the saved state when clicked and follows it once the PIN action succeeds, so a cancelled or refused PIN no longer leaves it showing the opposite state. A failed switch write is undone to the value read after the settings retry instead of the hard-coded inverse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): match noncanonical PWA Xtream category ids against their locks The PWA data source compared raw provider category ids such as "009" with locks stored as numbers, so such a category stayed visible while locked. Both sides are now compared in canonical numeric form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): close the M3U group editor on any relock The group management dialog lists every group name, locked ones included, even when it opened without lock toggles (unreadable lock store). It now closes on any relock, and the groups view re-checks the lock state before opening it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
203 files changed
+15022
-432
No files matched your search
@@ -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".
|
||||
@@ -176,9 +176,15 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
|
||||
},
|
||||
{
|
||||
method: 'dbSaveCategories',
|
||||
args: [playlistId, categories, 'live', categoryIds],
|
||||
args: [playlistId, categories, 'live', categoryIds, categoryIds],
|
||||
channel: 'DB_SAVE_CATEGORIES',
|
||||
forwardedArgs: [playlistId, categories, 'live', categoryIds],
|
||||
forwardedArgs: [
|
||||
playlistId,
|
||||
categories,
|
||||
'live',
|
||||
categoryIds,
|
||||
categoryIds,
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'dbGetAllCategories',
|
||||
@@ -192,6 +198,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
|
||||
channel: 'DB_UPDATE_CATEGORY_VISIBILITY',
|
||||
forwardedArgs: [categoryIds, true],
|
||||
},
|
||||
{
|
||||
method: 'dbSetCategoryLocks',
|
||||
args: [playlistId, 'live', categoryIds],
|
||||
channel: 'DB_SET_CATEGORY_LOCKS',
|
||||
forwardedArgs: [playlistId, 'live', categoryIds],
|
||||
},
|
||||
{
|
||||
method: 'dbHasContent',
|
||||
args: [playlistId, 'movie'],
|
||||
|
||||
@@ -99,6 +99,7 @@ const WINDOW_CONFIRM_CLOSE = 'WINDOW:CONFIRM_CLOSE';
|
||||
const WINDOW_CANCEL_CLOSE = 'WINDOW:CANCEL_CLOSE';
|
||||
const WINDOW_CLOSE_REQUESTED = 'WINDOW:CLOSE_REQUESTED';
|
||||
const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE';
|
||||
const PARENTAL_LOCK_SET_STATE = 'PARENTAL_LOCK:SET_STATE';
|
||||
|
||||
const dbSaveContentProgressListeners = new Set<
|
||||
(
|
||||
@@ -461,6 +462,8 @@ const electronApi: ElectronBridgeApi = {
|
||||
},
|
||||
setPlaybackKeepAwake: (active: boolean) =>
|
||||
ipcRenderer.invoke(PLAYBACK_SET_KEEP_AWAKE, active === true),
|
||||
setParentalLockState: (active: boolean) =>
|
||||
ipcRenderer.invoke(PARENTAL_LOCK_SET_STATE, active === true),
|
||||
fetchPlaylistByUrl: (
|
||||
url: string,
|
||||
title?: string,
|
||||
@@ -827,14 +830,16 @@ const electronApi: ElectronBridgeApi = {
|
||||
playlistId: string,
|
||||
categories: XtreamCategory[],
|
||||
type: string,
|
||||
hiddenCategoryXtreamIds?: number[]
|
||||
hiddenCategoryXtreamIds?: number[],
|
||||
lockedCategoryXtreamIds?: number[]
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SAVE_CATEGORIES',
|
||||
playlistId,
|
||||
categories,
|
||||
type,
|
||||
hiddenCategoryXtreamIds
|
||||
hiddenCategoryXtreamIds,
|
||||
lockedCategoryXtreamIds
|
||||
),
|
||||
dbGetAllCategories: (playlistId: string, type: string) =>
|
||||
ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type),
|
||||
@@ -844,6 +849,17 @@ const electronApi: ElectronBridgeApi = {
|
||||
categoryIds,
|
||||
hidden
|
||||
),
|
||||
dbSetCategoryLocks: (
|
||||
playlistId: string,
|
||||
type: string,
|
||||
lockedXtreamIds: number[]
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SET_CATEGORY_LOCKS',
|
||||
playlistId,
|
||||
type,
|
||||
lockedXtreamIds
|
||||
),
|
||||
dbHasContent: (playlistId: string, type: string) =>
|
||||
ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type),
|
||||
dbGetContent: (playlistId: string, type: string) =>
|
||||
|
||||
@@ -91,6 +91,9 @@ export const XTREAM_PRELOAD_TARGET_CASES: TargetCase[] = [
|
||||
categoryItems,
|
||||
'live',
|
||||
[91],
|
||||
// Parental-lock ids: the preload forwards the optional fifth
|
||||
// argument as-is, so an omitted one travels as undefined.
|
||||
undefined,
|
||||
],
|
||||
expectedMetadata: {
|
||||
...EMPTY_METADATA,
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ describe('category operation performance phases', () => {
|
||||
],
|
||||
'live',
|
||||
[102],
|
||||
undefined,
|
||||
recording.capture
|
||||
)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
@@ -4,7 +4,15 @@ import {
|
||||
getAllCategories,
|
||||
getCategories,
|
||||
saveCategories,
|
||||
setCategoryLocks,
|
||||
} from './category.operations';
|
||||
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { setParentalLockActive } from '../parental-lock-state';
|
||||
|
||||
function renderSql(query: SQL): string {
|
||||
return new SQLiteSyncDialect().sqlToQuery(query).sql;
|
||||
}
|
||||
|
||||
// Renderer consumers (XCategoryFromDb/XtreamCategoryFromDb) expect category
|
||||
// rows in this snake_case wire shape. A bare select() would return Drizzle's
|
||||
@@ -17,6 +25,7 @@ const categoryWireShape = {
|
||||
type: schema.categories.type,
|
||||
xtream_id: schema.categories.xtreamId,
|
||||
hidden: schema.categories.hidden,
|
||||
locked: schema.categories.locked,
|
||||
};
|
||||
|
||||
function createDbMock(existingCount = 0) {
|
||||
@@ -93,6 +102,7 @@ describe('category.operations', () => {
|
||||
type: 'live',
|
||||
xtreamId: 101,
|
||||
hidden: false,
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
playlistId: 'playlist-1',
|
||||
@@ -100,6 +110,7 @@ describe('category.operations', () => {
|
||||
type: 'live',
|
||||
xtreamId: 102,
|
||||
hidden: true,
|
||||
locked: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -125,6 +136,7 @@ describe('category.operations', () => {
|
||||
type: 'movies',
|
||||
xtreamId: 201,
|
||||
hidden: true,
|
||||
locked: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -143,3 +155,120 @@ describe('category.operations', () => {
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('category.operations parental lock', () => {
|
||||
afterEach(() => {
|
||||
setParentalLockActive(false);
|
||||
});
|
||||
|
||||
function createReadDb() {
|
||||
const orderBy = jest.fn().mockResolvedValue([]);
|
||||
const where = jest.fn().mockReturnValue({ orderBy });
|
||||
const from = jest.fn().mockReturnValue({ where });
|
||||
const select = jest.fn().mockReturnValue({ from });
|
||||
return { db: { select } as unknown as AppDatabase, where };
|
||||
}
|
||||
|
||||
it('adds the locked filter to visible-category reads only while active', async () => {
|
||||
const unlocked = createReadDb();
|
||||
await getCategories(unlocked.db, 'playlist-1', 'live');
|
||||
expect(renderSql(unlocked.where.mock.calls[0][0])).not.toContain(
|
||||
'"locked"'
|
||||
);
|
||||
|
||||
setParentalLockActive(true);
|
||||
const locked = createReadDb();
|
||||
await getCategories(locked.db, 'playlist-1', 'live');
|
||||
expect(renderSql(locked.where.mock.calls[0][0])).toContain(
|
||||
'"categories"."locked" = ?'
|
||||
);
|
||||
});
|
||||
|
||||
it('never filters the management read, which lists locked rows by design', async () => {
|
||||
setParentalLockActive(true);
|
||||
const { db, where } = createReadDb();
|
||||
await getAllCategories(db, 'playlist-1', 'movies');
|
||||
expect(renderSql(where.mock.calls[0][0])).not.toContain('"locked"');
|
||||
});
|
||||
|
||||
it('stamps locked from the caller-supplied provider ids on insert', async () => {
|
||||
const { db, values } = createDbMock(0);
|
||||
|
||||
await saveCategories(
|
||||
db,
|
||||
'playlist-1',
|
||||
[
|
||||
{ category_id: '1', category_name: 'Kids' },
|
||||
{ category_id: '2', category_name: 'Adult' },
|
||||
],
|
||||
'live',
|
||||
undefined,
|
||||
[2]
|
||||
);
|
||||
|
||||
expect(values).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ xtreamId: 1, locked: false }),
|
||||
expect.objectContaining({ xtreamId: 2, locked: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
function lockIndexDb() {
|
||||
const run = jest.fn();
|
||||
const where = jest.fn().mockReturnValue({ run });
|
||||
const set = jest.fn().mockReturnValue({ where });
|
||||
const update = jest.fn().mockReturnValue({ set });
|
||||
const transaction = jest.fn((callback: () => void) => callback());
|
||||
return {
|
||||
db: { update, transaction } as unknown as AppDatabase,
|
||||
run,
|
||||
set,
|
||||
transaction,
|
||||
where,
|
||||
};
|
||||
}
|
||||
|
||||
it('re-stamps one playlist/type in one transaction: clears everything, then locks the listed ids', async () => {
|
||||
const { db, run, set, transaction, where } = lockIndexDb();
|
||||
|
||||
await setCategoryLocks(db, 'playlist-1', 'live', [5, 5, 7, 1.5]);
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(set).toHaveBeenNthCalledWith(1, { locked: false });
|
||||
expect(set).toHaveBeenNthCalledWith(2, { locked: true });
|
||||
const lockScope = new SQLiteSyncDialect().sqlToQuery(
|
||||
where.mock.calls[1][0]
|
||||
);
|
||||
expect(lockScope.sql).toContain('"categories"."xtream_id" in (?, ?)');
|
||||
expect(lockScope.params).toEqual(['playlist-1', 'live', 5, 7]);
|
||||
});
|
||||
|
||||
it('only clears when no id is locked', async () => {
|
||||
const { db, set } = lockIndexDb();
|
||||
|
||||
await setCategoryLocks(db, 'playlist-1', 'series', []);
|
||||
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
expect(set).toHaveBeenCalledWith({ locked: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCategoryLocks atomicity', () => {
|
||||
it('runs both statements inside the transaction callback', async () => {
|
||||
const order: string[] = [];
|
||||
const run = jest.fn(() => order.push('run'));
|
||||
const where = jest.fn().mockReturnValue({ run });
|
||||
const set = jest.fn().mockReturnValue({ where });
|
||||
const update = jest.fn().mockReturnValue({ set });
|
||||
const transaction = jest.fn((callback: () => void) => {
|
||||
order.push('begin');
|
||||
callback();
|
||||
order.push('commit');
|
||||
});
|
||||
const db = { update, transaction } as unknown as AppDatabase;
|
||||
|
||||
await setCategoryLocks(db, 'playlist-1', 'live', [5]);
|
||||
|
||||
expect(order).toEqual(['begin', 'run', 'run', 'commit']);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { unlockedCategoryCondition } from '../parental-lock-state';
|
||||
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
|
||||
|
||||
type XtreamCategoryInput = {
|
||||
@@ -23,6 +24,7 @@ const categoryWireShape = {
|
||||
type: schema.categories.type,
|
||||
xtream_id: schema.categories.xtreamId,
|
||||
hidden: schema.categories.hidden,
|
||||
locked: schema.categories.locked,
|
||||
};
|
||||
|
||||
function normalizeXtreamCategoryId(
|
||||
@@ -37,9 +39,11 @@ function normalizeXtreamCategories(
|
||||
playlistId: string,
|
||||
categories: XtreamCategoryInput[],
|
||||
type: 'live' | 'movies' | 'series',
|
||||
hiddenCategoryXtreamIds?: number[]
|
||||
hiddenCategoryXtreamIds?: number[],
|
||||
lockedCategoryXtreamIds?: number[]
|
||||
): XtreamCategoryValue[] {
|
||||
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
|
||||
const lockedSet = new Set(lockedCategoryXtreamIds || []);
|
||||
|
||||
return categories.flatMap((category) => {
|
||||
const xtreamId = normalizeXtreamCategoryId(category.category_id);
|
||||
@@ -55,6 +59,7 @@ function normalizeXtreamCategories(
|
||||
type,
|
||||
xtreamId,
|
||||
hidden: hiddenSet.has(xtreamId),
|
||||
locked: lockedSet.has(xtreamId),
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -111,7 +116,8 @@ export async function getCategories(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, type),
|
||||
eq(schema.categories.hidden, false)
|
||||
eq(schema.categories.hidden, false),
|
||||
unlockedCategoryCondition()
|
||||
)
|
||||
)
|
||||
.orderBy(schema.categories.id);
|
||||
@@ -131,6 +137,7 @@ export async function saveCategories(
|
||||
categories: XtreamCategoryInput[],
|
||||
type: 'live' | 'movies' | 'series',
|
||||
hiddenCategoryXtreamIds?: number[],
|
||||
lockedCategoryXtreamIds?: number[],
|
||||
capturePhase?: DatabaseOperationPerformancePhaseCapture
|
||||
): Promise<{ success: boolean }> {
|
||||
if (!categories || categories.length === 0) {
|
||||
@@ -159,7 +166,8 @@ export async function saveCategories(
|
||||
playlistId,
|
||||
categories,
|
||||
type,
|
||||
hiddenCategoryXtreamIds
|
||||
hiddenCategoryXtreamIds,
|
||||
lockedCategoryXtreamIds
|
||||
),
|
||||
(result) => ({ itemCount: result.length })
|
||||
)
|
||||
@@ -167,7 +175,8 @@ export async function saveCategories(
|
||||
playlistId,
|
||||
categories,
|
||||
type,
|
||||
hiddenCategoryXtreamIds
|
||||
hiddenCategoryXtreamIds,
|
||||
lockedCategoryXtreamIds
|
||||
);
|
||||
|
||||
if (values.length === 0) {
|
||||
@@ -220,3 +229,43 @@ export async function updateCategoryVisibility(
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-stamps the parental lock index for one playlist and category type from
|
||||
* the renderer's lock store: listed provider ids become locked, every other
|
||||
* row of that playlist/type is unlocked. Idempotent by construction, so the
|
||||
* renderer can replay the store after a refresh or a backup restore.
|
||||
*/
|
||||
export async function setCategoryLocks(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
type: 'live' | 'movies' | 'series',
|
||||
lockedXtreamIds: number[]
|
||||
): Promise<{ success: boolean }> {
|
||||
const scope = and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, type)
|
||||
);
|
||||
const lockedIds = [
|
||||
...new Set(lockedXtreamIds.filter((id) => Number.isInteger(id))),
|
||||
];
|
||||
|
||||
// One transaction: a re-stamp that fails after the clear would otherwise
|
||||
// leave every category of this playlist/type unlocked while the lock
|
||||
// store still lists the intended locks. `.run()` (synchronous), not
|
||||
// `.execute()`: see playback-position.operations.ts.
|
||||
await db.transaction(() => {
|
||||
db.update(schema.categories).set({ locked: false }).where(scope).run();
|
||||
|
||||
if (lockedIds.length > 0) {
|
||||
db.update(schema.categories)
|
||||
.set({ locked: true })
|
||||
.where(
|
||||
and(scope, inArray(schema.categories.xtreamId, lockedIds))
|
||||
)
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
XtreamGlobalSearchResult,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import {
|
||||
unlockedCategoryCondition,
|
||||
unlockedCategorySql,
|
||||
} from '../parental-lock-state';
|
||||
import {
|
||||
countContentRowsByCategory,
|
||||
sumCategoryRowCounts,
|
||||
@@ -311,6 +315,7 @@ async function selectXtreamGlobalSearchCandidatesWithTitleIndex(
|
||||
)})
|
||||
AND ${sql.join(titleConditions, sql` AND `)}
|
||||
${excludeHidden ? sql`AND cat.hidden = 0` : sql``}
|
||||
${unlockedCategorySql()}
|
||||
ORDER BY c.title
|
||||
LIMIT ${candidateLimit}
|
||||
`)) as XtreamGlobalSearchCandidate[];
|
||||
@@ -359,6 +364,7 @@ async function selectXtreamGlobalSearchCandidatesWithFts(
|
||||
: sql``
|
||||
}
|
||||
${excludeHidden ? sql`AND cat.hidden = 0` : sql``}
|
||||
${unlockedCategorySql()}
|
||||
ORDER BY rank, c.title
|
||||
LIMIT ${candidateLimit}
|
||||
`)) as XtreamGlobalSearchCandidate[];
|
||||
@@ -382,6 +388,10 @@ async function selectXtreamGlobalSearchCandidatesWithContentScan(
|
||||
if (excludeHidden) {
|
||||
conditions.push(eq(schema.categories.hidden, false));
|
||||
}
|
||||
const unlockedCondition = unlockedCategoryCondition();
|
||||
if (unlockedCondition) {
|
||||
conditions.push(unlockedCondition);
|
||||
}
|
||||
|
||||
return db
|
||||
.select({
|
||||
@@ -694,7 +704,8 @@ export async function getContent(
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.content.type, type)
|
||||
eq(schema.content.type, type),
|
||||
unlockedCategoryCondition()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -775,6 +786,7 @@ function getGlobalRecentlyAddedByType(
|
||||
const whereConditions = [
|
||||
eq(schema.content.type, type),
|
||||
eq(schema.categories.hidden, false),
|
||||
unlockedCategoryCondition(),
|
||||
sql`${schema.content.added} <> ''`,
|
||||
sql`${schema.content.added} <= ${getXtreamRecentlyAddedMaxEpochSeconds()}`,
|
||||
];
|
||||
@@ -1074,6 +1086,10 @@ export async function searchContent(
|
||||
if (excludeHidden) {
|
||||
conditions.push(eq(schema.categories.hidden, false));
|
||||
}
|
||||
const unlockedCondition = unlockedCategoryCondition();
|
||||
if (unlockedCondition) {
|
||||
conditions.push(unlockedCondition);
|
||||
}
|
||||
|
||||
const query = db
|
||||
.select(selectContentFields())
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeTitleKeys,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { unlockedCategorySql } from '../parental-lock-state';
|
||||
|
||||
/**
|
||||
* Batched cross-playlist title matching for the actor page's "All portals"
|
||||
@@ -75,6 +76,7 @@ export async function matchTitles(
|
||||
WHERE content_title_fts MATCH ${matchQuery}
|
||||
AND c.type IN ('movie', 'series')
|
||||
AND cat.hidden = 0
|
||||
${unlockedCategorySql()}
|
||||
ORDER BY rank, c.title
|
||||
LIMIT ${PER_TITLE_CANDIDATE_LIMIT}
|
||||
`)) as TitleMatchRow[];
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type VodSourceCandidateRow,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { unlockedCategorySql } from '../parental-lock-state';
|
||||
import {
|
||||
caseInsensitiveGlobBody,
|
||||
caseInsensitiveGlobPattern,
|
||||
@@ -258,6 +259,7 @@ function scanCandidateQuery(
|
||||
WHERE c.type = 'movie'
|
||||
AND cat.hidden = 0
|
||||
AND p.type = 'xtream'
|
||||
${unlockedCategorySql()}
|
||||
AND ${wordMatches}
|
||||
${excludePlaylist}
|
||||
ORDER BY LENGTH(c.title), c.title
|
||||
@@ -292,6 +294,7 @@ function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) {
|
||||
AND c.type = 'movie'
|
||||
AND cat.hidden = 0
|
||||
AND p.type = 'xtream'
|
||||
${unlockedCategorySql()}
|
||||
${excludePlaylist}
|
||||
GROUP BY cat.playlist_id, c.xtream_id
|
||||
ORDER BY rank, c.title
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { and, eq, type SQL } from 'drizzle-orm';
|
||||
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
|
||||
function renderSql(query: SQL): string {
|
||||
return new SQLiteSyncDialect().sqlToQuery(query).sql;
|
||||
}
|
||||
import {
|
||||
isParentalLockActive,
|
||||
setParentalLockActive,
|
||||
unlockedCategoryCondition,
|
||||
unlockedCategorySql,
|
||||
} from './parental-lock-state';
|
||||
|
||||
describe('parental-lock-state', () => {
|
||||
afterEach(() => {
|
||||
setParentalLockActive(false);
|
||||
});
|
||||
|
||||
it('starts unlocked and contributes nothing to queries', () => {
|
||||
expect(isParentalLockActive()).toBe(false);
|
||||
expect(unlockedCategoryCondition()).toBeUndefined();
|
||||
expect(renderSql(unlockedCategorySql())).toBe('');
|
||||
// `and()` must tolerate the undefined condition so callers can add
|
||||
// it unconditionally.
|
||||
expect(
|
||||
and(eq(schema.categories.type, 'live'), undefined)
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('filters on categories.locked while active', () => {
|
||||
setParentalLockActive(true);
|
||||
|
||||
expect(isParentalLockActive()).toBe(true);
|
||||
expect(unlockedCategoryCondition()).toEqual(
|
||||
eq(schema.categories.locked, false)
|
||||
);
|
||||
expect(renderSql(unlockedCategorySql())).toContain(
|
||||
'AND cat.locked = 0'
|
||||
);
|
||||
});
|
||||
|
||||
it('coerces anything but true to inactive', () => {
|
||||
setParentalLockActive('yes' as unknown as boolean);
|
||||
expect(isParentalLockActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { eq, sql, type SQL } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
|
||||
/**
|
||||
* Process-wide parental lock enforcement flag for the SQLite worker.
|
||||
*
|
||||
* While active, every content-returning read appends "category is not
|
||||
* locked", so a surface added later is withheld by default instead of
|
||||
* leaking. The value arrives from the main process: seeded through
|
||||
* `workerData` when the worker is (re)started and updated by the
|
||||
* `DB_SET_PARENTAL_LOCK_STATE` request. It is deliberately not read from
|
||||
* settings inside the worker, which has no access to the renderer's
|
||||
* IndexedDB or to electron-conf.
|
||||
*/
|
||||
let parentalLockActive = false;
|
||||
|
||||
export function setParentalLockActive(active: boolean): void {
|
||||
parentalLockActive = active === true;
|
||||
}
|
||||
|
||||
export function isParentalLockActive(): boolean {
|
||||
return parentalLockActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drizzle condition for query-builder reads joined on `categories`;
|
||||
* `undefined` while unlocked so `and(...)` ignores it.
|
||||
*/
|
||||
export function unlockedCategoryCondition(): SQL | undefined {
|
||||
return parentalLockActive ? eq(schema.categories.locked, false) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw fragment for `sql` template reads that alias the categories table as
|
||||
* `cat`; empty while unlocked.
|
||||
*/
|
||||
export function unlockedCategorySql(): SQL {
|
||||
return parentalLockActive ? sql`AND cat.locked = 0` : sql``;
|
||||
}
|
||||
@@ -30,12 +30,27 @@ handleWorkerRequest(
|
||||
category_id: string | number;
|
||||
}>,
|
||||
type: 'live' | 'movies' | 'series',
|
||||
hiddenCategoryXtreamIds?: number[]
|
||||
hiddenCategoryXtreamIds?: number[],
|
||||
lockedCategoryXtreamIds?: number[]
|
||||
) => ({
|
||||
playlistId,
|
||||
categories,
|
||||
type,
|
||||
hiddenCategoryXtreamIds,
|
||||
lockedCategoryXtreamIds,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_SET_CATEGORY_LOCKS',
|
||||
(
|
||||
playlistId: string,
|
||||
type: 'live' | 'movies' | 'series',
|
||||
lockedXtreamIds: number[]
|
||||
) => ({
|
||||
playlistId,
|
||||
type,
|
||||
lockedXtreamIds: Array.isArray(lockedXtreamIds) ? lockedXtreamIds : [],
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -154,12 +154,13 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
|
||||
},
|
||||
{
|
||||
operation: 'DB_SAVE_CATEGORIES',
|
||||
args: [playlistId, categories, 'live', categoryIds],
|
||||
args: [playlistId, categories, 'live', categoryIds, categoryIds],
|
||||
payload: {
|
||||
playlistId,
|
||||
categories,
|
||||
type: 'live',
|
||||
hiddenCategoryXtreamIds: categoryIds,
|
||||
lockedCategoryXtreamIds: categoryIds,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -172,6 +173,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
|
||||
args: [categoryIds, true],
|
||||
payload: { categoryIds, hidden: true },
|
||||
},
|
||||
{
|
||||
operation: 'DB_SET_CATEGORY_LOCKS',
|
||||
args: [playlistId, 'live', categoryIds],
|
||||
payload: { playlistId, type: 'live', lockedXtreamIds: categoryIds },
|
||||
},
|
||||
{
|
||||
operation: 'DB_HAS_CONTENT',
|
||||
args: [playlistId, 'movie'],
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
type IpcHandler = (event: unknown, ...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
const mockRegisteredHandlers = new Map<string, IpcHandler>();
|
||||
const mockSetParentalLockState = jest.fn();
|
||||
const mockStoreGet = jest.fn();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn((channel: string, handler: IpcHandler) => {
|
||||
mockRegisteredHandlers.set(channel, handler);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../services/database-worker-client', () => ({
|
||||
databaseWorkerClient: {
|
||||
setParentalLockState: (...args: unknown[]) =>
|
||||
mockSetParentalLockState(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../services/store.service', () => ({
|
||||
PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED',
|
||||
store: { get: (...args: unknown[]) => mockStoreGet(...args) },
|
||||
}));
|
||||
|
||||
type Listener = (...args: unknown[]) => void;
|
||||
|
||||
function createSender() {
|
||||
const listeners = new Map<string, Listener>();
|
||||
return {
|
||||
id: 1,
|
||||
on: jest.fn((event: string, listener: Listener) => {
|
||||
listeners.set(event, listener);
|
||||
}),
|
||||
off: jest.fn(),
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
listeners.get(event)?.(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('parental-lock.events', () => {
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
mockRegisteredHandlers.clear();
|
||||
mockSetParentalLockState.mockReset().mockResolvedValue(undefined);
|
||||
mockStoreGet.mockReset().mockReturnValue(true);
|
||||
const module = await import('./parental-lock.events');
|
||||
module.default.bootstrapParentalLockEvents();
|
||||
});
|
||||
|
||||
it('forwards the renderer state to the worker and remembers it', async () => {
|
||||
const { getParentalLockActive } =
|
||||
await import('../services/parental-lock-state');
|
||||
const handler = mockRegisteredHandlers.get('PARENTAL_LOCK:SET_STATE');
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
await handler?.({ sender: createSender() }, false);
|
||||
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false);
|
||||
expect(getParentalLockActive()).toBe(false);
|
||||
|
||||
await handler?.({ sender: createSender() }, 'yes');
|
||||
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('locks again from the mirrored setting when the renderer reloads or dies', async () => {
|
||||
const handler = mockRegisteredHandlers.get('PARENTAL_LOCK:SET_STATE');
|
||||
const sender = createSender();
|
||||
|
||||
await handler?.({ sender }, false);
|
||||
mockSetParentalLockState.mockClear();
|
||||
|
||||
sender.emit('did-start-navigation', {
|
||||
isMainFrame: true,
|
||||
isSameDocument: true,
|
||||
});
|
||||
expect(mockSetParentalLockState).not.toHaveBeenCalled();
|
||||
|
||||
sender.emit('did-start-navigation', {
|
||||
isMainFrame: true,
|
||||
isSameDocument: false,
|
||||
});
|
||||
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(true);
|
||||
|
||||
mockStoreGet.mockReturnValue(false);
|
||||
sender.emit('render-process-gone');
|
||||
expect(mockSetParentalLockState).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('seeds the main-process copy from the mirrored setting at bootstrap', async () => {
|
||||
const { getParentalLockActive } =
|
||||
await import('../services/parental-lock-state');
|
||||
expect(getParentalLockActive()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ipcMain, WebContents } from 'electron';
|
||||
import { PARENTAL_LOCK_SET_STATE } from '@iptvnator/shared/interfaces';
|
||||
import { databaseWorkerClient } from '../services/database-worker-client';
|
||||
import {
|
||||
getParentalLockActive,
|
||||
setParentalLockActiveState,
|
||||
} from '../services/parental-lock-state';
|
||||
import { PARENTAL_LOCK_ENABLED, store } from '../services/store.service';
|
||||
|
||||
/**
|
||||
* Parental lock IPC.
|
||||
*
|
||||
* The renderer reports whether locked categories must be withheld (feature
|
||||
* enabled and no PIN entered). The value is kept in the main process and
|
||||
* pushed into the SQLite worker, which filters every content read on it.
|
||||
*
|
||||
* Like the playback keep-awake vote, the renderer's word must not outlive the
|
||||
* page that gave it: on reload, navigation or a dead render process the flag
|
||||
* falls back to the mirrored `parentalLockEnabled` setting, i.e. locked
|
||||
* while the feature is on. A crashed page can therefore never leave the
|
||||
* library unlocked.
|
||||
*/
|
||||
|
||||
const senderCleanups = new Map<number, () => void>();
|
||||
|
||||
export async function applyParentalLockState(active: boolean): Promise<void> {
|
||||
setParentalLockActiveState(active);
|
||||
try {
|
||||
await databaseWorkerClient.setParentalLockState(
|
||||
getParentalLockActive()
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to update parental lock state:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The state a renderer that has not announced itself yet must get. */
|
||||
export function defaultParentalLockState(): boolean {
|
||||
return store.get(PARENTAL_LOCK_ENABLED, false) === true;
|
||||
}
|
||||
|
||||
function watchSenderLifetime(sender: WebContents): void {
|
||||
const senderId = sender.id;
|
||||
if (senderCleanups.has(senderId)) {
|
||||
return;
|
||||
}
|
||||
const relock = () => {
|
||||
void applyParentalLockState(defaultParentalLockState()).catch(
|
||||
() => undefined
|
||||
);
|
||||
};
|
||||
const onNavigation = (
|
||||
event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>
|
||||
) => {
|
||||
if (event.isMainFrame && !event.isSameDocument) {
|
||||
relock();
|
||||
}
|
||||
};
|
||||
const onDestroyed = () => {
|
||||
relock();
|
||||
senderCleanups.get(senderId)?.();
|
||||
senderCleanups.delete(senderId);
|
||||
};
|
||||
sender.on('destroyed', onDestroyed);
|
||||
sender.on('render-process-gone', relock);
|
||||
sender.on('did-start-navigation', onNavigation);
|
||||
senderCleanups.set(senderId, () => {
|
||||
sender.off('destroyed', onDestroyed);
|
||||
sender.off('render-process-gone', relock);
|
||||
sender.off('did-start-navigation', onNavigation);
|
||||
});
|
||||
}
|
||||
|
||||
export default class ParentalLockEvents {
|
||||
static bootstrapParentalLockEvents(): Electron.IpcMain {
|
||||
setParentalLockActiveState(defaultParentalLockState());
|
||||
return ipcMain;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle(PARENTAL_LOCK_SET_STATE, async (event, active: boolean) => {
|
||||
watchSenderLifetime(event.sender);
|
||||
await applyParentalLockState(active === true);
|
||||
});
|
||||
@@ -38,6 +38,7 @@ const handlers = new Map<string, SettingsUpdateHandler>();
|
||||
const mockStoreGet = jest.fn();
|
||||
const mockStoreSet = jest.fn();
|
||||
const mockUpdateSettings = jest.fn();
|
||||
const mockApplyParentalLockState = jest.fn();
|
||||
const mockIpcHandle = jest.fn(
|
||||
(channel: string, handler: SettingsUpdateHandler): void => {
|
||||
handlers.set(channel, handler);
|
||||
@@ -61,12 +62,18 @@ jest.mock('../services/store.service', () => ({
|
||||
PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
|
||||
VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS,
|
||||
VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE,
|
||||
PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED',
|
||||
store: {
|
||||
get: mockStoreGet,
|
||||
set: mockStoreSet,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./parental-lock.events', () => ({
|
||||
applyParentalLockState: (...args: unknown[]) =>
|
||||
mockApplyParentalLockState(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../server/http-server', () => ({
|
||||
httpServer: {
|
||||
updateSettings: mockUpdateSettings,
|
||||
@@ -84,6 +91,7 @@ describe('SETTINGS_UPDATE', () => {
|
||||
mockStoreGet.mockReset();
|
||||
mockStoreSet.mockReset();
|
||||
mockUpdateSettings.mockReset();
|
||||
mockApplyParentalLockState.mockReset().mockResolvedValue(undefined);
|
||||
mockStoreGet.mockImplementation(
|
||||
(_key: string, fallbackValue: unknown): unknown => fallbackValue
|
||||
);
|
||||
@@ -136,6 +144,35 @@ describe('SETTINGS_UPDATE', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('mirrors the parental lock switch and releases the worker only on switch-off', () => {
|
||||
// Off → on: persist; the renderer announces its own live state.
|
||||
settingsUpdateHandler({}, { parentalLockEnabled: true });
|
||||
expect(mockStoreSet).toHaveBeenCalledWith(
|
||||
'PARENTAL_LOCK_ENABLED',
|
||||
true
|
||||
);
|
||||
expect(mockApplyParentalLockState).not.toHaveBeenCalled();
|
||||
|
||||
// An ordinary save carrying the unchanged flag must not re-lock a
|
||||
// worker the renderer believes is unlocked.
|
||||
mockStoreGet.mockImplementation((key: string, fallback: unknown) =>
|
||||
key === 'PARENTAL_LOCK_ENABLED' ? true : fallback
|
||||
);
|
||||
settingsUpdateHandler(
|
||||
{},
|
||||
{ parentalLockEnabled: true, showCaptions: true }
|
||||
);
|
||||
expect(mockApplyParentalLockState).not.toHaveBeenCalled();
|
||||
|
||||
// On → off: release at once.
|
||||
settingsUpdateHandler({}, { parentalLockEnabled: false });
|
||||
expect(mockStoreSet).toHaveBeenCalledWith(
|
||||
'PARENTAL_LOCK_ENABLED',
|
||||
false
|
||||
);
|
||||
expect(mockApplyParentalLockState).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('normalizes external-player arguments and preserves explicit false reuse settings', () => {
|
||||
settingsUpdateHandler(
|
||||
{},
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
EMBEDDED_MPV_FRAME_COPY,
|
||||
MPV_PLAYER_ARGUMENTS,
|
||||
MPV_REUSE_INSTANCE,
|
||||
PARENTAL_LOCK_ENABLED,
|
||||
STARTUP_WINDOW_MODE,
|
||||
PORTAL_CONNECTIVITY_GUARD,
|
||||
store,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from '../services/store.service';
|
||||
import { httpServer } from '../server/http-server';
|
||||
import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard';
|
||||
import { applyParentalLockState } from './parental-lock.events';
|
||||
import { persistAppUpdateChannel } from '../services/app-update-channel';
|
||||
|
||||
export default class SettingsEvents {
|
||||
@@ -42,6 +44,22 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
|
||||
setHostConnectivityGuardEnabled(enabled);
|
||||
}
|
||||
|
||||
// Mirrored so the database worker and a reloaded renderer start locked
|
||||
// whenever the feature is on. The LIVE enforcement state is the
|
||||
// renderer's to announce through PARENTAL_LOCK_SET_STATE: every full
|
||||
// settings save carries this flag unchanged, so applying it here would
|
||||
// silently re-lock the worker under a renderer that still shows
|
||||
// "unlocked". Only a switch-off releases the worker at once — nothing
|
||||
// may stay withheld once the feature is gone.
|
||||
if (arg.parentalLockEnabled !== undefined) {
|
||||
const enabled = arg.parentalLockEnabled === true;
|
||||
const wasEnabled = store.get(PARENTAL_LOCK_ENABLED, false) === true;
|
||||
store.set(PARENTAL_LOCK_ENABLED, enabled);
|
||||
if (wasEnabled && !enabled) {
|
||||
void applyParentalLockState(false).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (arg.mpvPlayerArguments !== undefined) {
|
||||
store.set(
|
||||
MPV_PLAYER_ARGUMENTS,
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('DatabaseWorkerClient', () => {
|
||||
nativeModuleSearchPaths: [
|
||||
'/mock/resources/app.asar.unpacked/node_modules',
|
||||
],
|
||||
parentalLockActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { app } from 'electron';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { getParentalLockActive } from './parental-lock-state';
|
||||
import { Worker } from 'worker_threads';
|
||||
import type {
|
||||
DbOperationEvent,
|
||||
@@ -83,6 +84,19 @@ export class DatabaseWorkerClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips the worker's parental lock filter. Awaits readiness so the
|
||||
* message is never lost to a worker that has not started, and is posted
|
||||
* on the request port so later reads observe it in order.
|
||||
*/
|
||||
async setParentalLockState(active: boolean): Promise<void> {
|
||||
await this.ensureWorker();
|
||||
this.worker?.postMessage({
|
||||
type: 'parental-lock',
|
||||
active: active === true,
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(operationId: string): Promise<{ success: boolean }> {
|
||||
if (!operationId) {
|
||||
return { success: false };
|
||||
@@ -157,6 +171,10 @@ export class DatabaseWorkerClient {
|
||||
this.worker = new Worker(workerURL, {
|
||||
workerData: {
|
||||
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
|
||||
// Seeded here rather than requested afterwards, so a
|
||||
// restarted worker never answers a read unfiltered while
|
||||
// the lock state is still in flight.
|
||||
parentalLockActive: getParentalLockActive(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Main-process copy of the parental lock enforcement flag.
|
||||
*
|
||||
* The renderer owns the unlock decision and reports it over
|
||||
* `PARENTAL_LOCK_SET_STATE`; this module remembers the latest value so the
|
||||
* database worker can be seeded with it whenever it is (re)started, and so a
|
||||
* renderer reload or crash can fall back to "locked while the feature is on"
|
||||
* without waiting for a page that may never come back.
|
||||
*/
|
||||
let parentalLockActive = false;
|
||||
|
||||
export function getParentalLockActive(): boolean {
|
||||
return parentalLockActive;
|
||||
}
|
||||
|
||||
export function setParentalLockActiveState(active: boolean): void {
|
||||
parentalLockActive = active === true;
|
||||
}
|
||||
@@ -33,6 +33,14 @@ export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
|
||||
/** Desktop portal request cooldown; absent means enabled. */
|
||||
export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD';
|
||||
|
||||
/**
|
||||
* Parental lock feature switch, mirrored from the renderer's settings by the
|
||||
* SETTINGS_UPDATE handler. Read when the database worker starts and when a
|
||||
* renderer reloads or dies, so the SQLite reads are locked before any page
|
||||
* has announced its lock state. Absent means off.
|
||||
*/
|
||||
export const PARENTAL_LOCK_ENABLED = 'PARENTAL_LOCK_ENABLED';
|
||||
|
||||
/**
|
||||
* Update channel (`stable` / `nightly`). Mirrored here from the renderer's
|
||||
* settings by the SETTINGS_UPDATE handler because the startup update check
|
||||
@@ -78,6 +86,7 @@ export type StoreType = {
|
||||
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
|
||||
[STARTUP_WINDOW_MODE]: StartupWindowMode;
|
||||
[PORTAL_CONNECTIVITY_GUARD]: boolean;
|
||||
[PARENTAL_LOCK_ENABLED]: boolean;
|
||||
[APP_UPDATE_CHANNEL]: AppUpdateChannel;
|
||||
[TRUSTED_LOCAL_EPG_SOURCES]: string[];
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ import { shutdownMpvSession } from '../events/mpv-session.service';
|
||||
import PlayerEvents from '../events/player.events';
|
||||
import { shutdownVlcSession } from '../events/vlc-session.service';
|
||||
import PlaylistEvents from '../events/playlist.events';
|
||||
import ParentalLockEvents from '../events/parental-lock.events';
|
||||
import RemoteControlEvents from '../events/remote-control.events';
|
||||
import SettingsEvents from '../events/settings.events';
|
||||
import SharedEvents from '../events/shared.events';
|
||||
@@ -85,6 +86,7 @@ export function bootstrapDeferredEvents(
|
||||
SharedEvents.bootstrapSharedEvents();
|
||||
PlayerEvents.bootstrapPlayerEvents();
|
||||
SettingsEvents.bootstrapSettingsEvents();
|
||||
ParentalLockEvents.bootstrapParentalLockEvents();
|
||||
StalkerEvents.bootstrapStalkerEvents();
|
||||
XtreamEvents.bootstrapXtreamEvents();
|
||||
registerStreamProbeHandlers();
|
||||
|
||||
@@ -6,6 +6,7 @@ export const DB_WORKER_OPERATIONS = [
|
||||
'DB_SAVE_CATEGORIES',
|
||||
'DB_GET_ALL_CATEGORIES',
|
||||
'DB_UPDATE_CATEGORY_VISIBILITY',
|
||||
'DB_SET_CATEGORY_LOCKS',
|
||||
'DB_HAS_CONTENT',
|
||||
'DB_GET_CONTENT',
|
||||
'DB_GET_GLOBAL_RECENTLY_ADDED',
|
||||
@@ -131,6 +132,16 @@ export interface DbWorkerCancelMessage {
|
||||
operationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-process control message, not a renderer request: flips the worker's
|
||||
* parental lock filter. Ordered with the requests on the same port, so a
|
||||
* read posted after it observes the new state.
|
||||
*/
|
||||
export interface DbWorkerParentalLockMessage {
|
||||
type: 'parental-lock';
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface DbWorkerReadyMessage {
|
||||
type: 'ready';
|
||||
}
|
||||
@@ -158,7 +169,9 @@ export interface DbWorkerResponseMessage<TResult = unknown> {
|
||||
}
|
||||
|
||||
export type DbWorkerIncomingMessage =
|
||||
DbWorkerRequestMessage | DbWorkerCancelMessage;
|
||||
| DbWorkerRequestMessage
|
||||
| DbWorkerCancelMessage
|
||||
| DbWorkerParentalLockMessage;
|
||||
|
||||
export type DbWorkerMessage =
|
||||
| DbWorkerReadyMessage
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
closeWorkerDatabase,
|
||||
getWorkerDatabase,
|
||||
} from './database.worker-connection';
|
||||
import { parentPort } from 'worker_threads';
|
||||
import { parentPort, workerData } from 'worker_threads';
|
||||
import type {
|
||||
ContentMetadataPatch,
|
||||
VodSourcePin,
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
getCategories,
|
||||
hasCategories,
|
||||
saveCategories,
|
||||
setCategoryLocks,
|
||||
updateCategoryVisibility,
|
||||
} from '../database/operations/category.operations';
|
||||
import { setParentalLockActive } from '../database/parental-lock-state';
|
||||
import {
|
||||
addFavorite,
|
||||
getAllGlobalFavorites,
|
||||
@@ -133,6 +135,12 @@ import {
|
||||
} from './operation-progress-throttle';
|
||||
|
||||
const loggerLabel = '[DB Worker]';
|
||||
// Seeded by the main process so a (re)started worker is locked before its
|
||||
// first read; DB_SET_PARENTAL_LOCK_STATE updates it afterwards.
|
||||
setParentalLockActive(
|
||||
(workerData as { parentalLockActive?: unknown } | undefined)
|
||||
?.parentalLockActive === true
|
||||
);
|
||||
const batchDelayMs = Number.parseInt(
|
||||
process.env['IPTVNATOR_DB_WORKER_BATCH_DELAY_MS'] ?? '0',
|
||||
10
|
||||
@@ -440,6 +448,7 @@ async function executeRequest(
|
||||
}>;
|
||||
type: 'live' | 'movies' | 'series';
|
||||
hiddenCategoryXtreamIds?: number[];
|
||||
lockedCategoryXtreamIds?: number[];
|
||||
};
|
||||
const capturePhase =
|
||||
createWorkerPerformancePhaseAdapter(performanceCapture);
|
||||
@@ -449,6 +458,7 @@ async function executeRequest(
|
||||
payload.categories,
|
||||
payload.type,
|
||||
payload.hiddenCategoryXtreamIds,
|
||||
payload.lockedCategoryXtreamIds,
|
||||
capturePhase
|
||||
);
|
||||
}
|
||||
@@ -473,6 +483,20 @@ async function executeRequest(
|
||||
);
|
||||
}
|
||||
|
||||
case 'DB_SET_CATEGORY_LOCKS': {
|
||||
const payload = message.payload as {
|
||||
playlistId: string;
|
||||
type: 'live' | 'movies' | 'series';
|
||||
lockedXtreamIds: number[];
|
||||
};
|
||||
return setCategoryLocks(
|
||||
db,
|
||||
payload.playlistId,
|
||||
payload.type,
|
||||
payload.lockedXtreamIds
|
||||
);
|
||||
}
|
||||
|
||||
case 'DB_HAS_CONTENT': {
|
||||
const payload = message.payload as {
|
||||
playlistId: string;
|
||||
@@ -1246,6 +1270,11 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'parental-lock') {
|
||||
setParentalLockActive(message.active === true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'cancel') {
|
||||
const activeOperation = activeOperations.get(message.operationId);
|
||||
if (activeOperation) {
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@@ -12,7 +12,9 @@ async function openSettings(page: Page) {
|
||||
|
||||
/** Settings render one section page at a time — open it via the rail. */
|
||||
async function openSettingsSection(page: Page, sectionId: string) {
|
||||
await page.locator(`[data-test-id="settings-section-${sectionId}"]`).click();
|
||||
await page
|
||||
.locator(`[data-test-id="settings-section-${sectionId}"]`)
|
||||
.click();
|
||||
await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`));
|
||||
}
|
||||
|
||||
@@ -41,11 +43,11 @@ test.describe('Settings', () => {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'playback');
|
||||
|
||||
const playerSelect = page.locator('[data-test-id="select-video-player"]');
|
||||
|
||||
await expect(playerSelect).toContainText(
|
||||
/Video\.js/i
|
||||
const playerSelect = page.locator(
|
||||
'[data-test-id="select-video-player"]'
|
||||
);
|
||||
|
||||
await expect(playerSelect).toContainText(/Video\.js/i);
|
||||
await playerSelect.click();
|
||||
await page.locator('mat-option[data-test-id="html5"]').click();
|
||||
|
||||
@@ -54,9 +56,7 @@ test.describe('Settings', () => {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'playback');
|
||||
|
||||
await expect(playerSelect).toContainText(
|
||||
/HTML5/i
|
||||
);
|
||||
await expect(playerSelect).toContainText(/HTML5/i);
|
||||
});
|
||||
|
||||
test('@settings @web Opt out of shared web player controls', async ({
|
||||
@@ -235,13 +235,71 @@ test.describe('Settings', () => {
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
test('@settings @web Parental lock — set a PIN, lock, survive a reload, unlock', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'parental');
|
||||
|
||||
const enableToggle = page.locator(
|
||||
'[data-test-id="parental-lock-enabled"] button[role="switch"]'
|
||||
);
|
||||
const pinInput = page.locator('[data-test-id="parental-lock-pin"]');
|
||||
const pinConfirm = page.locator(
|
||||
'[data-test-id="parental-lock-pin-confirm"]'
|
||||
);
|
||||
const pinSubmit = page.locator(
|
||||
'[data-test-id="parental-lock-pin-submit"]'
|
||||
);
|
||||
const lockNow = page.locator('[data-test-id="parental-lock-lock-now"]');
|
||||
const unlock = page.locator('[data-test-id="parental-lock-unlock"]');
|
||||
const headerLock = page.locator(
|
||||
'[data-test-id="header-parental-lock"]'
|
||||
);
|
||||
|
||||
// Enabling asks for a new PIN twice; the parent stays unlocked.
|
||||
await expect(enableToggle).toHaveAttribute('aria-checked', 'false');
|
||||
await enableToggle.click();
|
||||
await expect(pinInput).toBeVisible();
|
||||
await pinInput.fill('2468');
|
||||
await pinConfirm.fill('2468');
|
||||
await pinSubmit.click();
|
||||
await expect(enableToggle).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(lockNow).toBeVisible();
|
||||
await expect(headerLock).toBeVisible();
|
||||
|
||||
// Lock now flips the state; a reload keeps the lock (never persisted
|
||||
// as unlocked).
|
||||
await lockNow.click();
|
||||
await expect(unlock).toBeVisible();
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'parental');
|
||||
await expect(enableToggle).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(unlock).toBeVisible();
|
||||
|
||||
// A wrong PIN is refused, the right one unlocks.
|
||||
await unlock.click();
|
||||
await expect(pinInput).toBeVisible();
|
||||
await pinInput.fill('0000');
|
||||
await pinSubmit.click();
|
||||
await expect(
|
||||
page.locator('[data-test-id="parental-lock-pin-error"]')
|
||||
).toBeVisible();
|
||||
await pinInput.fill('2468');
|
||||
await pinSubmit.click();
|
||||
await expect(lockNow).toBeVisible();
|
||||
|
||||
// The header button locks from anywhere.
|
||||
await headerLock.click();
|
||||
await expect(unlock).toBeVisible();
|
||||
});
|
||||
|
||||
test('@settings @web Change app language', async ({ page }) => {
|
||||
await openSettings(page);
|
||||
const languageSelect = page.locator('[data-test-id="select-language"]');
|
||||
|
||||
await expect(languageSelect).toContainText(
|
||||
'English'
|
||||
);
|
||||
await expect(languageSelect).toContainText('English');
|
||||
await languageSelect.click();
|
||||
await page.locator('mat-option[data-test-id="de"]').click();
|
||||
|
||||
@@ -249,9 +307,7 @@ test.describe('Settings', () => {
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
|
||||
await expect(languageSelect).toContainText(
|
||||
'Deutsch'
|
||||
);
|
||||
await expect(languageSelect).toContainText('Deutsch');
|
||||
});
|
||||
|
||||
test('@settings @search @web Search settings from the header and open a result', async ({
|
||||
|
||||
@@ -17,9 +17,11 @@ import { EMPTY, of } from 'rxjs';
|
||||
import {
|
||||
DataService,
|
||||
EpgSourceSettingsService,
|
||||
ParentalLockService,
|
||||
SettingsStore,
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service';
|
||||
import {
|
||||
Language,
|
||||
Settings,
|
||||
@@ -97,6 +99,14 @@ describe('AppComponent', () => {
|
||||
imports: [AppComponent],
|
||||
providers: [
|
||||
provideMockStore(),
|
||||
// The parental lock boots from AppComponent; its collaborators
|
||||
// (Xtream/Stalker stores, SQLite bridge) are out of scope here.
|
||||
MockProvider(ParentalLockService, {
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
MockProvider(ParentalLockEnforcementService, {
|
||||
start: jest.fn(),
|
||||
}),
|
||||
{
|
||||
provide: Actions,
|
||||
useValue: new Actions(EMPTY),
|
||||
|
||||
@@ -28,6 +28,7 @@ import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
|
||||
import { filter, take } from 'rxjs';
|
||||
import {
|
||||
DataService,
|
||||
ParentalLockService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
EpgSourceSettingsService,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppDateLocaleService } from './app-date-locales';
|
||||
import { SettingsService } from './services/settings.service';
|
||||
import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service';
|
||||
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
|
||||
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
|
||||
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
|
||||
@@ -83,6 +85,10 @@ export class AppComponent implements OnInit {
|
||||
private settingsStore = inject(SettingsStore);
|
||||
private readonly epgSources = inject(EpgSourceSettingsService);
|
||||
private playbackKeepAwake = inject(PlaybackKeepAwakeService);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly parentalLockEnforcement = inject(
|
||||
ParentalLockEnforcementService
|
||||
);
|
||||
private playlistOpenRequests = inject(PlaylistOpenRequestService);
|
||||
private runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
@@ -108,6 +114,11 @@ export class AppComponent implements OnInit {
|
||||
// (Electron powerSaveBlocker / PWA Screen Wake Lock, issue #1095).
|
||||
this.playbackKeepAwake.start();
|
||||
|
||||
// Parental lock: load the PIN hash and lock store, then keep the
|
||||
// in-memory catalogs in step with lock/unlock (issue #285).
|
||||
void this.parentalLock.initialize();
|
||||
this.parentalLockEnforcement.start();
|
||||
|
||||
effect(() => {
|
||||
const size = this.settingsStore.coverSize?.() ?? 'medium';
|
||||
document.documentElement.dataset.coverSize = size;
|
||||
|
||||
@@ -33,13 +33,17 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui/stalker-connection-editor';
|
||||
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
provideParentalLockPlaylistCleanup,
|
||||
DataService,
|
||||
} from '@iptvnator/services';
|
||||
import { dbConfig } from '@iptvnator/shared/interfaces';
|
||||
import { AppConfig } from '../environments/environment';
|
||||
import { routes } from './app.routes';
|
||||
import { ElectronService } from './services/electron.service';
|
||||
import { ExternalPlaybackService } from './services/external-playback.service';
|
||||
import { PlayerService } from './services/player.service';
|
||||
import { provideParentalLockPrompt } from './services/parental-lock-prompt.service';
|
||||
import { providePortalPlaybackPositions } from './services/portal-playback-positions.service';
|
||||
import { PwaService } from './services/pwa.service';
|
||||
import { shouldEnableServiceWorker } from './services/runtime-config';
|
||||
@@ -160,6 +164,8 @@ export const appConfig: ApplicationConfig = {
|
||||
useExisting: LazyStalkerPlaylistConnectionEditor,
|
||||
},
|
||||
...provideWorkspaceShellActions(),
|
||||
...provideParentalLockPrompt(),
|
||||
provideParentalLockPlaylistCleanup(),
|
||||
...provideXtreamDataSource(),
|
||||
{
|
||||
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { ParentalLockService, SettingsStore } from '@iptvnator/services';
|
||||
|
||||
describe('app routes', () => {
|
||||
let workspaceRoute: import('@angular/router').Route | undefined;
|
||||
@@ -67,6 +67,52 @@ describe('app routes', () => {
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('waits for the parental lock state before activating workspace children', async () => {
|
||||
let releaseLock!: () => void;
|
||||
const lockPending = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
const initialize = jest.fn(() => lockPending);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
loadSettings: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
{ provide: ParentalLockService, useValue: { initialize } },
|
||||
],
|
||||
});
|
||||
const settingsReadyResolver =
|
||||
workspaceRoute?.resolve?.['settingsReady'];
|
||||
if (typeof settingsReadyResolver !== 'function') {
|
||||
throw new Error('resolver missing');
|
||||
}
|
||||
let resolved = false;
|
||||
const resolution = TestBed.runInInjectionContext(() =>
|
||||
Promise.resolve(
|
||||
(
|
||||
settingsReadyResolver as import('@angular/router').ResolveFn<void>
|
||||
)(
|
||||
{} as import('@angular/router').ActivatedRouteSnapshot,
|
||||
{} as import('@angular/router').RouterStateSnapshot
|
||||
)
|
||||
)
|
||||
).then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(initialize).toHaveBeenCalled();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
releaseLock();
|
||||
await resolution;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('waits for settings before activating workspace children', async () => {
|
||||
let releaseSettings!: () => void;
|
||||
const settingsPending = new Promise<void>((resolve) => {
|
||||
@@ -79,6 +125,12 @@ describe('app routes', () => {
|
||||
provide: SettingsStore,
|
||||
useValue: { loadSettings },
|
||||
},
|
||||
{
|
||||
provide: ParentalLockService,
|
||||
useValue: {
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const settingsReadyResolver =
|
||||
@@ -120,8 +172,7 @@ describe('app routes', () => {
|
||||
(route) => route.path === 'playlists/:id'
|
||||
);
|
||||
const loadChildren = playlistRoute?.loadChildren as
|
||||
| (() => Promise<typeof workspaceChildren>)
|
||||
| undefined;
|
||||
(() => Promise<typeof workspaceChildren>) | undefined;
|
||||
const m3uRoutes = (await loadChildren?.()) ?? [];
|
||||
const defaultRoute = m3uRoutes.find((route) => route.path === '');
|
||||
const favoritesRoute = m3uRoutes.find(
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, Routes } from '@angular/router';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
ParentalLockService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
|
||||
import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard';
|
||||
|
||||
const settingsReadyResolver = () => inject(SettingsStore).loadSettings();
|
||||
// The workspace activates only once settings AND the parental lock state
|
||||
// (PIN, lock store) are known: before that the lock reads as off and a
|
||||
// slower IndexedDB read would let the catalogs admit protected rows.
|
||||
const settingsReadyResolver = async () => {
|
||||
const settingsStore = inject(SettingsStore);
|
||||
const parentalLock = inject(ParentalLockService);
|
||||
await Promise.all([
|
||||
settingsStore.loadSettings(),
|
||||
parentalLock.initialize(),
|
||||
]);
|
||||
};
|
||||
|
||||
const workspaceEntryRedirect = async () =>
|
||||
inject(WorkspaceStartupPreferencesService).resolveInitialWorkspacePath();
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { ParentalLockEnforcementService } from './parental-lock-enforcement.service';
|
||||
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
|
||||
|
||||
interface Applier {
|
||||
apply(): Promise<void>;
|
||||
failClosedNow(): void;
|
||||
applyXtream(version: number): Promise<void>;
|
||||
applyStalker(): Promise<void>;
|
||||
}
|
||||
|
||||
describe('ParentalLockEnforcementService', () => {
|
||||
const router = { url: '/', navigate: jest.fn() };
|
||||
const activeChannel = signal<{ group?: { title: string } } | null>(null);
|
||||
const dispatch = jest.fn();
|
||||
const lockedStalkerIds = new Set<string>();
|
||||
const parentalLock = {
|
||||
version: signal(0),
|
||||
active: signal(false),
|
||||
registerBusyProbe: jest.fn(),
|
||||
isXtreamCategoryLocked: jest.fn(() => false),
|
||||
isStalkerCategoryLocked: jest.fn(
|
||||
(_playlistId: string, _type: string, id: unknown) =>
|
||||
id !== null &&
|
||||
id !== undefined &&
|
||||
lockedStalkerIds.has(String(id))
|
||||
),
|
||||
isM3uGroupLocked: jest.fn(() => false),
|
||||
};
|
||||
const stalkerStore = {
|
||||
currentPlaylist: signal<{ _id: string } | null>({ _id: 'stalker-1' }),
|
||||
selectedContentType: signal<string>('vod'),
|
||||
selectedCategoryId: signal<string | null>('*'),
|
||||
selectedItem: signal<{
|
||||
category_id?: string;
|
||||
tv_genre_id?: string;
|
||||
} | null>(null),
|
||||
clearSelectedItem: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
};
|
||||
const xtreamStore = {
|
||||
playlistId: signal<string | null>('xtream-1'),
|
||||
selectedCategoryId: signal<number | null>(null),
|
||||
selectedItem: signal<{ category_id?: number } | null>(null),
|
||||
reloadCategories: jest.fn(async () => undefined),
|
||||
reloadCachedContent: jest.fn(async () => undefined),
|
||||
refreshSearchResults: jest.fn(async () => undefined),
|
||||
withholdCatalog: jest.fn(),
|
||||
clearSearchResults: jest.fn(),
|
||||
getCategoriesBySelectedType: jest.fn(() => [
|
||||
{ id: 7, xtream_id: 70 },
|
||||
{ id: 8, xtream_id: 80 },
|
||||
]),
|
||||
setSelectedItem: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
};
|
||||
const xtreamDataSource = {
|
||||
getAllCategories: jest.fn(async () => [
|
||||
{ id: 7, xtream_id: 70 },
|
||||
{ id: 8, xtream_id: 80 },
|
||||
{ id: 55, xtream_id: 550 },
|
||||
{ id: 99, xtream_id: 990 },
|
||||
]),
|
||||
};
|
||||
let service: Applier;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
lockedStalkerIds.clear();
|
||||
router.url = '/';
|
||||
parentalLock.active.set(false);
|
||||
parentalLock.isXtreamCategoryLocked.mockReturnValue(false);
|
||||
stalkerStore.selectedCategoryId.set('*');
|
||||
stalkerStore.selectedItem.set(null);
|
||||
xtreamStore.selectedCategoryId.set(null);
|
||||
xtreamStore.selectedItem.set(null);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{ provide: XtreamStore, useValue: xtreamStore },
|
||||
{ provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource },
|
||||
{ provide: StalkerStore, useValue: stalkerStore },
|
||||
{ provide: Router, useValue: router },
|
||||
{
|
||||
provide: Store,
|
||||
useValue: { selectSignal: () => activeChannel, dispatch },
|
||||
},
|
||||
{
|
||||
provide: PlaybackKeepAwakeService,
|
||||
useValue: { hasPlayingVideo: () => false },
|
||||
},
|
||||
],
|
||||
});
|
||||
activeChannel.set(null);
|
||||
service = TestBed.inject(
|
||||
ParentalLockEnforcementService
|
||||
) as unknown as Applier;
|
||||
});
|
||||
|
||||
describe('M3U', () => {
|
||||
it('resets a locked playing channel before awaiting the portal reloads', async () => {
|
||||
router.url = '/workspace/playlists/m3u-1';
|
||||
activeChannel.set({ group: { title: 'Adult' } });
|
||||
parentalLock.isM3uGroupLocked.mockReturnValue(true);
|
||||
let releaseReload: () => void = () => undefined;
|
||||
xtreamStore.reloadCategories.mockImplementationOnce(
|
||||
() => new Promise<void>((resolve) => (releaseReload = resolve))
|
||||
);
|
||||
|
||||
const applying = service.apply();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(parentalLock.isM3uGroupLocked).toHaveBeenCalledWith(
|
||||
'm3u-1',
|
||||
'Adult'
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseReload();
|
||||
await applying;
|
||||
parentalLock.isM3uGroupLocked.mockReturnValue(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets a channel of a locked group as soon as it becomes active while locked', () => {
|
||||
// Numeric zapping, next/previous or a remote command can select a
|
||||
// channel without any lock-version change following it.
|
||||
router.url = '/workspace/playlists/m3u-1';
|
||||
parentalLock.active.set(true);
|
||||
parentalLock.isM3uGroupLocked.mockImplementation(
|
||||
(_playlistId: string, group: string) => group === 'Adult'
|
||||
);
|
||||
TestBed.runInInjectionContext(() =>
|
||||
(service as unknown as { start(): void }).start()
|
||||
);
|
||||
TestBed.flushEffects();
|
||||
dispatch.mockClear();
|
||||
|
||||
activeChannel.set({ group: { title: 'News' } });
|
||||
TestBed.flushEffects();
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
|
||||
activeChannel.set({ group: { title: 'Adult' } });
|
||||
TestBed.flushEffects();
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
parentalLock.isM3uGroupLocked.mockReset();
|
||||
parentalLock.isM3uGroupLocked.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('Stalker', () => {
|
||||
it('leaves the Stalker route when the Stalker step cannot load', async () => {
|
||||
router.url = '/workspace/stalker/stalker-1/itv';
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
(
|
||||
service as unknown as {
|
||||
loadStalkerEnforcement: () => Promise<unknown>;
|
||||
}
|
||||
).loadStalkerEnforcement = () =>
|
||||
Promise.reject(new Error('ChunkLoadError'));
|
||||
|
||||
await expect(service.applyStalker()).resolves.toBeUndefined();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'sources',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves the Stalker route synchronously on relock while the step is not loaded', () => {
|
||||
router.url = '/workspace/stalker/stalker-1/itv';
|
||||
(
|
||||
service as unknown as {
|
||||
loadStalkerEnforcement: () => Promise<unknown>;
|
||||
}
|
||||
).loadStalkerEnforcement = () => new Promise(() => undefined);
|
||||
|
||||
service.failClosedNow();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'sources',
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs a preloaded Stalker step synchronously on relock', async () => {
|
||||
router.url = '/workspace/stalker/stalker-1/vod/42';
|
||||
await service.applyStalker(); // loads the step
|
||||
lockedStalkerIds.add('9');
|
||||
stalkerStore.selectedItem.set({ category_id: '9' });
|
||||
stalkerStore.clearSelectedItem.mockClear();
|
||||
|
||||
service.failClosedNow();
|
||||
|
||||
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not load the Stalker step outside a Stalker route', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/live';
|
||||
const load = jest.spyOn(
|
||||
service as unknown as { loadStalkerEnforcement: () => unknown },
|
||||
'loadStalkerEnforcement'
|
||||
);
|
||||
|
||||
await service.applyStalker();
|
||||
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a detail opened from All whose own genre is withheld', async () => {
|
||||
router.url = '/workspace/stalker/stalker-1/vod/42';
|
||||
lockedStalkerIds.add('9');
|
||||
stalkerStore.selectedItem.set({ category_id: '9' });
|
||||
|
||||
await service.applyStalker();
|
||||
|
||||
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
|
||||
// "All" itself is not locked, so the category stays selected.
|
||||
expect(stalkerStore.setSelectedCategory).not.toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'stalker',
|
||||
'stalker-1',
|
||||
'vod',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a detail from All alone when its genre is not locked', async () => {
|
||||
router.url = '/workspace/stalker/stalker-1/vod/42';
|
||||
lockedStalkerIds.add('9');
|
||||
stalkerStore.selectedItem.set({ category_id: '3' });
|
||||
|
||||
await service.applyStalker();
|
||||
|
||||
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('judges a live channel from All by its genre, not by category_id', async () => {
|
||||
router.url = '/workspace/stalker/stalker-1/itv';
|
||||
stalkerStore.selectedContentType.set('itv');
|
||||
lockedStalkerIds.add('9');
|
||||
stalkerStore.selectedItem.set({
|
||||
tv_genre_id: '9',
|
||||
category_id: '3',
|
||||
});
|
||||
|
||||
await service.applyStalker();
|
||||
|
||||
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'stalker',
|
||||
'stalker-1',
|
||||
'itv',
|
||||
]);
|
||||
stalkerStore.selectedContentType.set('vod');
|
||||
});
|
||||
|
||||
it('steps off a locked selected category', async () => {
|
||||
router.url = '/workspace/stalker/stalker-1/vod';
|
||||
lockedStalkerIds.add('9');
|
||||
stalkerStore.selectedCategoryId.set('9');
|
||||
|
||||
await service.applyStalker();
|
||||
|
||||
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
|
||||
expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Xtream', () => {
|
||||
function lockProvider(providerId: number): void {
|
||||
parentalLock.active.set(true);
|
||||
parentalLock.isXtreamCategoryLocked.mockImplementation(
|
||||
(_p: string, _t: string, id: number) => id === providerId
|
||||
);
|
||||
}
|
||||
|
||||
it('clears a selected item whose category the lock store withholds', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/42';
|
||||
lockProvider(990);
|
||||
xtreamStore.selectedItem.set({ category_id: 99 });
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.reloadCategories).toHaveBeenCalled();
|
||||
expect(xtreamDataSource.getAllCategories).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
'movies'
|
||||
);
|
||||
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
|
||||
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
'xtream-1',
|
||||
'vod',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a detail from a category that is merely hidden, not locked', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/42';
|
||||
lockProvider(990);
|
||||
// Row 55 is absent from the (hidden-filtered) visible list but
|
||||
// exists unlocked in the unfiltered rows.
|
||||
xtreamStore.selectedItem.set({ category_id: 55 });
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('steps off a selected locked category', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/live';
|
||||
lockProvider(990);
|
||||
xtreamStore.selectedCategoryId.set(99);
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
|
||||
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
'xtream-1',
|
||||
'live',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails closed when the category rows cannot be read', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/42';
|
||||
parentalLock.active.set(true);
|
||||
xtreamDataSource.getAllCategories.mockRejectedValueOnce(
|
||||
new Error('db')
|
||||
);
|
||||
xtreamStore.selectedItem.set({ category_id: 7 });
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('skips the post-reload checks while unlocked and hands the reloads a publish guard', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod';
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.withholdCatalog).not.toHaveBeenCalled();
|
||||
expect(xtreamDataSource.getAllCategories).not.toHaveBeenCalled();
|
||||
const guard = xtreamStore.reloadCategories.mock.calls[0][0] as
|
||||
(() => boolean) | undefined;
|
||||
expect(guard?.()).toBe(true);
|
||||
parentalLock.version.set(parentalLock.version() + 1);
|
||||
expect(guard?.()).toBe(false);
|
||||
});
|
||||
|
||||
it('abandons the reloads and checks once the Xtream playlist is switched', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/7';
|
||||
parentalLock.active.set(true);
|
||||
xtreamStore.selectedCategoryId.set(7);
|
||||
parentalLock.isXtreamCategoryLocked.mockReturnValue(true);
|
||||
let guard: () => boolean = () => true;
|
||||
(xtreamStore.reloadCategories as jest.Mock).mockImplementationOnce(
|
||||
async (shouldPublish: () => boolean) => {
|
||||
guard = shouldPublish;
|
||||
xtreamStore.playlistId.set('xtream-2');
|
||||
router.url = '/workspace/xtreams/xtream-2/vod';
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await service.applyXtream(parentalLock.version());
|
||||
expect(guard()).toBe(false);
|
||||
expect(xtreamStore.refreshSearchResults).not.toHaveBeenCalled();
|
||||
expect(
|
||||
xtreamDataSource.getAllCategories
|
||||
).not.toHaveBeenCalled();
|
||||
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
xtreamStore.playlistId.set('xtream-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('re-runs the stored in-portal search after the reload', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/search';
|
||||
|
||||
await service.applyXtream(parentalLock.version());
|
||||
|
||||
expect(xtreamStore.refreshSearchResults).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears on relock, synchronously, a detail whose category is not in the visible list', () => {
|
||||
// Opened through search from a manually hidden category: the
|
||||
// on-screen list cannot place it, and the unfiltered lookup is
|
||||
// an awaited read that may hang.
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/55/900';
|
||||
xtreamStore.selectedItem.set({ category_id: 55 });
|
||||
|
||||
service.failClosedNow();
|
||||
|
||||
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
'xtream-1',
|
||||
'vod',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a detail on relock whose visible category is not locked', () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/7/900';
|
||||
xtreamStore.selectedItem.set({ category_id: 7 });
|
||||
|
||||
service.failClosedNow();
|
||||
|
||||
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed synchronously on relock: detail, catalog and search', () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/vod/42';
|
||||
lockProvider(70);
|
||||
xtreamStore.selectedItem.set({ category_id: 7 });
|
||||
|
||||
service.failClosedNow();
|
||||
|
||||
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
|
||||
expect(xtreamStore.withholdCatalog).toHaveBeenCalled();
|
||||
expect(xtreamStore.clearSearchResults).toHaveBeenCalled();
|
||||
expect(xtreamStore.reloadCategories).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ParentalLockEnforcementService apply serialization', () => {
|
||||
it('runs applies one at a time and abandons a result superseded by a newer version', async () => {
|
||||
const version = signal(0);
|
||||
let releaseReload: () => void = () => undefined;
|
||||
const xtreamStore = {
|
||||
playlistId: signal('xtream-1'),
|
||||
selectedCategoryId: signal<number | null>(99),
|
||||
selectedItem: signal(null),
|
||||
reloadCategories: jest.fn(
|
||||
() => new Promise<void>((resolve) => (releaseReload = resolve))
|
||||
),
|
||||
reloadCachedContent: jest.fn(async () => undefined),
|
||||
refreshSearchResults: jest.fn(async () => undefined),
|
||||
withholdCatalog: jest.fn(),
|
||||
clearSearchResults: jest.fn(),
|
||||
getCategoriesBySelectedType: jest.fn(() => [] as unknown[]),
|
||||
setSelectedItem: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: ParentalLockService,
|
||||
useValue: {
|
||||
version,
|
||||
active: signal(true),
|
||||
isXtreamCategoryLocked: jest.fn(() => false),
|
||||
registerBusyProbe: jest.fn(),
|
||||
isStalkerCategoryLocked: jest.fn(() => false),
|
||||
isM3uGroupLocked: jest.fn(() => false),
|
||||
},
|
||||
},
|
||||
{ provide: XtreamStore, useValue: xtreamStore },
|
||||
{
|
||||
// No rows: the selected category cannot be placed and
|
||||
// fails closed once an apply gets to judge it.
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
useValue: { getAllCategories: jest.fn(async () => []) },
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: { currentPlaylist: signal(null) },
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: {
|
||||
url: '/workspace/xtreams/xtream-1/live',
|
||||
navigate: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: {
|
||||
selectSignal: () => signal(null),
|
||||
dispatch: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaybackKeepAwakeService,
|
||||
useValue: { hasPlayingVideo: () => false },
|
||||
},
|
||||
],
|
||||
});
|
||||
const service = TestBed.inject(ParentalLockEnforcementService);
|
||||
TestBed.runInInjectionContext(() => service.start());
|
||||
TestBed.flushEffects();
|
||||
|
||||
// Unlock: the reload is held open...
|
||||
version.set(1);
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
|
||||
|
||||
// ...and "Lock now" arrives meanwhile: no second reload starts yet,
|
||||
// but the catalog is withheld at once rather than behind the hung
|
||||
// read.
|
||||
xtreamStore.withholdCatalog.mockClear();
|
||||
version.set(2);
|
||||
TestBed.flushEffects();
|
||||
expect(xtreamStore.withholdCatalog).toHaveBeenCalledTimes(1);
|
||||
await Promise.resolve();
|
||||
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The superseded apply must not act on its (unlocked) rows.
|
||||
releaseReload();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(2);
|
||||
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
|
||||
|
||||
releaseReload();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ParentalLockEnforcementService busy probe', () => {
|
||||
it('counts playing audio (the radio player) as activity', () => {
|
||||
let probe: (() => boolean) | undefined;
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: ParentalLockService,
|
||||
useValue: {
|
||||
version: signal(0),
|
||||
registerBusyProbe: (fn: () => boolean) => (probe = fn),
|
||||
},
|
||||
},
|
||||
{ provide: XtreamStore, useValue: {} },
|
||||
{ provide: XTREAM_DATA_SOURCE, useValue: {} },
|
||||
{ provide: StalkerStore, useValue: {} },
|
||||
{ provide: Router, useValue: { url: '/' } },
|
||||
{
|
||||
provide: Store,
|
||||
useValue: { selectSignal: () => signal(null) },
|
||||
},
|
||||
{
|
||||
provide: PlaybackKeepAwakeService,
|
||||
useValue: { hasPlayingVideo: () => false },
|
||||
},
|
||||
],
|
||||
});
|
||||
const service = TestBed.inject(ParentalLockEnforcementService);
|
||||
TestBed.runInInjectionContext(() => service.start());
|
||||
expect(probe?.()).toBe(false);
|
||||
|
||||
const audio = document.createElement('audio');
|
||||
Object.defineProperty(audio, 'paused', { value: false });
|
||||
Object.defineProperty(audio, 'ended', { value: false });
|
||||
document.body.appendChild(audio);
|
||||
try {
|
||||
expect(probe?.()).toBe(true);
|
||||
} finally {
|
||||
audio.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import {
|
||||
effect,
|
||||
EnvironmentInjector,
|
||||
inject,
|
||||
Injectable,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ChannelActions, selectActive } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces';
|
||||
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
|
||||
|
||||
const XTREAM_ROUTE =
|
||||
/^\/workspace\/xtreams\/([^/?#]+)\/(live|vod|series)(?:\/(\d+))?/;
|
||||
export const STALKER_ROUTE =
|
||||
/^\/workspace\/stalker\/([^/?#]+)\/(itv|vod|series|radio)(?:\/([^/?#]+))?/;
|
||||
|
||||
/**
|
||||
* Applies a parental lock change to the parts of the app that hold catalog
|
||||
* data in memory. The stores and the SQLite worker filter what they READ;
|
||||
* this service makes them read again and steps off anything that is now
|
||||
* withheld — a selected category, a playing channel — so a locked category
|
||||
* cannot stay on screen just because it was opened before the lock.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ParentalLockEnforcementService {
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
|
||||
private readonly injector = inject(EnvironmentInjector);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly keepAwake = inject(PlaybackKeepAwakeService);
|
||||
private readonly activeChannel = this.store.selectSignal(selectActive);
|
||||
private started = false;
|
||||
private lastVersion = -1;
|
||||
private applyChain: Promise<void> = Promise.resolve();
|
||||
private stalkerModule: StalkerEnforcementModule | null = null;
|
||||
private stalkerModuleLoad: Promise<StalkerEnforcementModule> | null = null;
|
||||
|
||||
start(): void {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
// Playback counts as activity: video through the keep-awake
|
||||
// tracker, and audio (the radio player) read directly, since the
|
||||
// keep-awake service deliberately ignores <audio>.
|
||||
this.parentalLock.registerBusyProbe(
|
||||
() => this.keepAwake.hasPlayingVideo() || hasPlayingAudio()
|
||||
);
|
||||
// The Stalker step is loaded as soon as a Stalker route opens, so a
|
||||
// relock there can run it synchronously instead of awaiting a chunk.
|
||||
this.router.events?.subscribe((event) => {
|
||||
if (
|
||||
event instanceof NavigationEnd &&
|
||||
STALKER_ROUTE.test(event.urlAfterRedirects)
|
||||
) {
|
||||
void this.loadStalker().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
// Any path that makes a channel active (numeric zapping, next /
|
||||
// previous, remote commands, a stale list) is checked here: a
|
||||
// channel of a locked group is reset as soon as it becomes active.
|
||||
effect(() => {
|
||||
this.activeChannel();
|
||||
untracked(() => {
|
||||
if (this.parentalLock.active()) {
|
||||
this.applyM3u();
|
||||
}
|
||||
});
|
||||
});
|
||||
effect(() => {
|
||||
const version = this.parentalLock.version();
|
||||
untracked(() => {
|
||||
if (version === this.lastVersion) {
|
||||
return;
|
||||
}
|
||||
const first = this.lastVersion === -1;
|
||||
this.lastVersion = version;
|
||||
if (first) {
|
||||
return;
|
||||
}
|
||||
// Fail closed NOW, ahead of the serialized queue: an earlier
|
||||
// apply may still be waiting on a slow (or hung) read, and
|
||||
// the catalog, details and playback read while unlocked must
|
||||
// not stay usable until it settles.
|
||||
if (this.parentalLock.active()) {
|
||||
this.failClosedNow();
|
||||
}
|
||||
this.scheduleApply();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies run one at a time. `ElectronXtreamDataSource` shares in-flight
|
||||
* reads per playlist and type, so an unlock refresh still running when
|
||||
* "Lock now" arrives would hand the relock its unfiltered rows. Each
|
||||
* apply also abandons its result once a newer version exists, leaving
|
||||
* the queued apply to read the latest state.
|
||||
*/
|
||||
private scheduleApply(): void {
|
||||
this.applyChain = this.applyChain
|
||||
.then(() => this.apply())
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply the parental lock change.',
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The synchronous half of a relock: M3U channel, Stalker selection (its
|
||||
* step is lazy, so it runs as soon as the chunk is there), the Xtream
|
||||
* detail the lock store already places in a locked category, the
|
||||
* catalog lists and the stored search. The queued apply then reloads
|
||||
* the filtered rows and repeats the checks against them.
|
||||
*/
|
||||
private failClosedNow(): void {
|
||||
this.applyM3u();
|
||||
this.failClosedStalkerNow();
|
||||
const playlistId = this.xtreamStore.playlistId?.();
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
this.stepOffLockedXtreamSelection(
|
||||
playlistId,
|
||||
XTREAM_ROUTE.exec(this.router.url)
|
||||
);
|
||||
this.xtreamStore.withholdCatalog?.();
|
||||
this.xtreamStore.clearSearchResults?.();
|
||||
}
|
||||
|
||||
private async apply(): Promise<void> {
|
||||
const version = this.parentalLock.version();
|
||||
// The synchronous surfaces first: an M3U channel or a Stalker
|
||||
// selection must not keep playing behind a slow Xtream database or
|
||||
// provider reload — the Xtream store stays populated after leaving
|
||||
// that portal, so its reload runs on every apply.
|
||||
this.applyM3u();
|
||||
await this.applyStalker();
|
||||
await this.applyXtream(version);
|
||||
}
|
||||
|
||||
private async applyXtream(version: number): Promise<void> {
|
||||
const playlistId = this.xtreamStore.playlistId?.();
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
// A playlist switch retires the apply as a newer lock version
|
||||
// does: the store now holds another portal, whose own load reads
|
||||
// under the current lock state.
|
||||
const shouldPublish = (): boolean =>
|
||||
this.parentalLock.version() === version &&
|
||||
this.xtreamStore.playlistId?.() === playlistId;
|
||||
await this.xtreamStore.reloadCategories(shouldPublish);
|
||||
await this.xtreamStore.reloadCachedContent(shouldPublish);
|
||||
if (!shouldPublish()) {
|
||||
return;
|
||||
}
|
||||
// Stored in-portal search results are a separate array the search
|
||||
// page renders directly; re-run the search so it reads filtered.
|
||||
await this.xtreamStore.refreshSearchResults?.();
|
||||
if (!shouldPublish() || !this.parentalLock.active()) {
|
||||
return;
|
||||
}
|
||||
await this.stepOffWithheldXtreamSelection(playlistId, shouldPublish);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-reload check of the selected Xtream category and item, judged by
|
||||
* the LOCK STORE through the unfiltered category rows (hidden and locked
|
||||
* ones included): the reloaded list also omits categories the user
|
||||
* merely hid, which are not parental-locked. The item is judged on its
|
||||
* own category — opened from "All", recently added or search it has no
|
||||
* selected category to vanish with. Rows that cannot be read fail
|
||||
* closed.
|
||||
*/
|
||||
private async stepOffWithheldXtreamSelection(
|
||||
playlistId: string,
|
||||
shouldPublish: () => boolean
|
||||
): Promise<void> {
|
||||
const match = XTREAM_ROUTE.exec(this.router.url);
|
||||
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
|
||||
if (!categoryType) {
|
||||
return;
|
||||
}
|
||||
const rows = await this.xtreamDataSource
|
||||
.getAllCategories(playlistId, categoryType)
|
||||
.catch(() => null);
|
||||
if (!shouldPublish()) {
|
||||
return;
|
||||
}
|
||||
const isWithheld = (categoryId: unknown): boolean => {
|
||||
const id = Number(categoryId);
|
||||
if (
|
||||
categoryId === null ||
|
||||
categoryId === undefined ||
|
||||
!Number.isFinite(id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const row = rows?.find((candidate) => candidate.id === id);
|
||||
return (
|
||||
!row ||
|
||||
this.parentalLock.isXtreamCategoryLocked(
|
||||
playlistId,
|
||||
categoryType,
|
||||
row.xtream_id
|
||||
)
|
||||
);
|
||||
};
|
||||
const selectedItem = this.xtreamStore.selectedItem?.() as {
|
||||
category_id?: string | number;
|
||||
} | null;
|
||||
const itemWithheld = isWithheld(selectedItem?.category_id);
|
||||
const categoryWithheld = isWithheld(
|
||||
this.xtreamStore.selectedCategoryId()
|
||||
);
|
||||
if (!itemWithheld && !categoryWithheld) {
|
||||
return;
|
||||
}
|
||||
this.xtreamStore.setSelectedItem(null);
|
||||
if (categoryWithheld) {
|
||||
this.xtreamStore.setSelectedCategory(null);
|
||||
}
|
||||
if (match && match[1] === playlistId) {
|
||||
void this.router.navigate([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
match[1],
|
||||
match[2],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a selected Xtream item whose category the lock store already
|
||||
* says is locked. Electron rows carry the SQLite category row id; the
|
||||
* category list still on screen maps it to the provider id the store is
|
||||
* keyed by, the PWA carries the provider id directly. An item whose
|
||||
* category is not in that list (a manually hidden category, opened
|
||||
* through search) cannot be judged without an awaited read that may
|
||||
* hang, so it is cleared too: fail closed.
|
||||
*/
|
||||
private stepOffLockedXtreamSelection(
|
||||
playlistId: string,
|
||||
match: RegExpExecArray | null
|
||||
): void {
|
||||
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
|
||||
const selectedItem = this.xtreamStore.selectedItem?.() as {
|
||||
category_id?: string | number;
|
||||
} | null;
|
||||
const categoryId = Number(selectedItem?.category_id);
|
||||
if (!categoryType || !selectedItem || !Number.isFinite(categoryId)) {
|
||||
return;
|
||||
}
|
||||
const category = this.xtreamStore
|
||||
.getCategoriesBySelectedType()
|
||||
.find(
|
||||
(candidate) =>
|
||||
Number((candidate as { id?: number }).id) === categoryId ||
|
||||
Number(
|
||||
(candidate as { category_id?: string }).category_id
|
||||
) === categoryId
|
||||
) as { xtream_id?: number; category_id?: string } | undefined;
|
||||
const providerId = Number(category?.xtream_id ?? category?.category_id);
|
||||
if (
|
||||
category &&
|
||||
Number.isFinite(providerId) &&
|
||||
!this.parentalLock.isXtreamCategoryLocked(
|
||||
playlistId,
|
||||
categoryType,
|
||||
providerId
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.xtreamStore.setSelectedItem(null);
|
||||
if (match && match[1] === playlistId) {
|
||||
void this.router.navigate([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
match[1],
|
||||
match[2],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** The dynamic import; a field so specs can substitute it. */
|
||||
loadStalkerEnforcement = (): Promise<StalkerEnforcementModule> =>
|
||||
import('./parental-lock-stalker-enforcement');
|
||||
|
||||
/** Loads the Stalker step once; a failed load may be retried. */
|
||||
private loadStalker(): Promise<StalkerEnforcementModule> {
|
||||
this.stalkerModuleLoad ??= this.loadStalkerEnforcement().then(
|
||||
(module) => (this.stalkerModule = module),
|
||||
(error: unknown) => {
|
||||
this.stalkerModuleLoad = null;
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
return this.stalkerModuleLoad;
|
||||
}
|
||||
|
||||
/**
|
||||
* The relock's Stalker step, synchronously: run it when the chunk is
|
||||
* there, otherwise leave the Stalker route at once (its route session
|
||||
* clears the selection and the live layout stops playback) rather than
|
||||
* wait for a chunk that may be slow or never arrive.
|
||||
*/
|
||||
private failClosedStalkerNow(): void {
|
||||
if (!STALKER_ROUTE.test(this.router.url)) {
|
||||
return;
|
||||
}
|
||||
if (!this.stalkerModule) {
|
||||
void this.router.navigate(['/workspace', 'sources']);
|
||||
return;
|
||||
}
|
||||
this.stalkerModule.applyParentalLockToStalker(
|
||||
this.injector,
|
||||
this.parentalLock,
|
||||
this.router
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Stalker data layer stays off the initial path: the step is loaded
|
||||
* only while a Stalker route is open. Outside one there is nothing to
|
||||
* step off — the Stalker route session clears the selection (and the
|
||||
* live layout its playback) when the route is left.
|
||||
*/
|
||||
private async applyStalker(): Promise<void> {
|
||||
if (!STALKER_ROUTE.test(this.router.url)) {
|
||||
return;
|
||||
}
|
||||
let module: StalkerEnforcementModule;
|
||||
try {
|
||||
module = await this.loadStalker();
|
||||
} catch (error) {
|
||||
// Fail closed (e.g. a stale PWA page whose chunk is gone): leave
|
||||
// the Stalker route. Its route session clears the selection and
|
||||
// the live layout stops playback on the way out, so no locked
|
||||
// channel keeps playing; the Xtream step still runs afterwards.
|
||||
console.error(
|
||||
'The parental lock Stalker step could not be loaded.',
|
||||
error
|
||||
);
|
||||
void this.router.navigate(['/workspace', 'sources']);
|
||||
return;
|
||||
}
|
||||
module.applyParentalLockToStalker(
|
||||
this.injector,
|
||||
this.parentalLock,
|
||||
this.router
|
||||
);
|
||||
}
|
||||
|
||||
private applyM3u(): void {
|
||||
const channel = this.activeChannel();
|
||||
const playlistId = this.activeM3uPlaylistId();
|
||||
if (!channel || !playlistId) {
|
||||
return;
|
||||
}
|
||||
const groupTitle = channel.group?.title ?? '';
|
||||
if (this.parentalLock.isM3uGroupLocked(playlistId, groupTitle)) {
|
||||
this.store.dispatch(ChannelActions.resetActiveChannel());
|
||||
}
|
||||
}
|
||||
|
||||
private activeM3uPlaylistId(): string | null {
|
||||
const match = /^\/workspace\/playlists\/([^/?#]+)/.exec(
|
||||
this.router.url
|
||||
);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
type StalkerEnforcementModule =
|
||||
typeof import('./parental-lock-stalker-enforcement');
|
||||
|
||||
function hasPlayingAudio(): boolean {
|
||||
return Array.from(document.querySelectorAll('audio')).some(
|
||||
(audio) => !audio.paused && !audio.ended
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Lazy boundary for the parental-lock prompt: the prompt service imports
|
||||
* this file dynamically, so the PIN dialog and the @angular/forms and
|
||||
* Material form-field modules it brings stay off the initial path. The
|
||||
* boundary is a local file backed by a file-level alias, like the other
|
||||
* root-shell lazy dialogs (see docs/architecture/nx-workspace-boundaries.md).
|
||||
*/
|
||||
export { ParentalLockPinDialogComponent } from '@iptvnator/ui/components/parental-lock-pin-dialog';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { of } from 'rxjs';
|
||||
import { AppParentalLockPromptService } from './parental-lock-prompt.service';
|
||||
|
||||
describe('AppParentalLockPromptService', () => {
|
||||
const dialog = { open: jest.fn() };
|
||||
let service: AppParentalLockPromptService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
AppParentalLockPromptService,
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(AppParentalLockPromptService);
|
||||
});
|
||||
|
||||
it('loads the PIN dialog on demand and returns the entered PIN', async () => {
|
||||
const open = jest.fn(() => ({ afterClosed: () => of('1234') }));
|
||||
service.loadPinDialog = jest.fn(async () => ({
|
||||
ParentalLockPinDialogComponent: { open },
|
||||
})) as never;
|
||||
|
||||
await expect(service.requestPin({ mode: 'set' })).resolves.toBe('1234');
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
dialog,
|
||||
expect.objectContaining({ mode: 'set' })
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a failed dialog chunk load as a cancelled prompt', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
service.loadPinDialog = jest.fn(async () => {
|
||||
throw new Error('ChunkLoadError');
|
||||
}) as never;
|
||||
|
||||
await expect(
|
||||
service.requestPin({ mode: 'unlock' })
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
PARENTAL_LOCK_PROMPT,
|
||||
ParentalLockPrompt,
|
||||
ParentalLockPromptRequest,
|
||||
} from '@iptvnator/services';
|
||||
|
||||
/**
|
||||
* Application implementation of the parental lock prompt: the shared PIN
|
||||
* dialog from `@iptvnator/ui/components`, which the data-access lock
|
||||
* service cannot import itself. The dialog is loaded on first use (the
|
||||
* service is constructed at startup, the dialog is not needed until a PIN
|
||||
* is asked for).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppParentalLockPromptService implements ParentalLockPrompt {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
/** The dynamic import; a field so specs can substitute it. */
|
||||
loadPinDialog = () => import('./parental-lock-pin-dialog.lazy');
|
||||
|
||||
async requestPin(
|
||||
request: ParentalLockPromptRequest
|
||||
): Promise<string | null> {
|
||||
let module: Awaited<ReturnType<typeof this.loadPinDialog>>;
|
||||
try {
|
||||
module = await this.loadPinDialog();
|
||||
} catch (error) {
|
||||
// A failed chunk load reads as a cancelled prompt: the gate it
|
||||
// guards stays closed.
|
||||
console.error(
|
||||
'The parental lock PIN dialog could not be loaded.',
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const dialogRef = module.ParentalLockPinDialogComponent.open(
|
||||
this.dialog,
|
||||
{
|
||||
mode: request.mode,
|
||||
verify: request.verify,
|
||||
throttle: request.throttle,
|
||||
titleKey: request.titleKey,
|
||||
descriptionKey: request.descriptionKey,
|
||||
}
|
||||
);
|
||||
const pin = await firstValueFrom(dialogRef.afterClosed());
|
||||
return typeof pin === 'string' ? pin : null;
|
||||
}
|
||||
}
|
||||
|
||||
export function provideParentalLockPrompt(): Provider[] {
|
||||
return [
|
||||
AppParentalLockPromptService,
|
||||
{
|
||||
provide: PARENTAL_LOCK_PROMPT,
|
||||
useExisting: AppParentalLockPromptService,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { toParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
|
||||
import { STALKER_ROUTE } from './parental-lock-enforcement.service';
|
||||
|
||||
/**
|
||||
* The Stalker step of `ParentalLockEnforcementService`, in its own file so
|
||||
* the enforcement service can import it dynamically: a static import of the
|
||||
* Stalker store would put the whole Stalker data layer back on the initial
|
||||
* path (see docs/architecture/nx-workspace-boundaries.md).
|
||||
*/
|
||||
export function applyParentalLockToStalker(
|
||||
injector: Injector,
|
||||
parentalLock: ParentalLockService,
|
||||
router: Router
|
||||
): void {
|
||||
const stalkerStore = injector.get(StalkerStore);
|
||||
const playlist = stalkerStore.currentPlaylist();
|
||||
const playlistId = playlist?._id;
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
const contentType = toParentalLockStalkerCategoryType(
|
||||
stalkerStore.selectedContentType()
|
||||
);
|
||||
if (!contentType) {
|
||||
return;
|
||||
}
|
||||
const selectedCategoryId = stalkerStore.selectedCategoryId();
|
||||
const categoryWithheld =
|
||||
!!selectedCategoryId &&
|
||||
parentalLock.isStalkerCategoryLocked(
|
||||
playlistId,
|
||||
contentType,
|
||||
selectedCategoryId
|
||||
);
|
||||
// An item opened from "All" (`*`) or search has its own genre to be
|
||||
// judged by; the list dropping its row is not enough. Live and radio
|
||||
// rows carry that genre in `tv_genre_id`, VOD and series rows in
|
||||
// `category_id` (the store's withheld filter applies the same rule).
|
||||
const selectedItem = stalkerStore.selectedItem?.() as {
|
||||
category_id?: string | number;
|
||||
tv_genre_id?: string | number;
|
||||
} | null;
|
||||
const itemCategoryId =
|
||||
contentType === 'itv' || contentType === 'radio'
|
||||
? selectedItem?.tv_genre_id
|
||||
: selectedItem?.category_id;
|
||||
const itemWithheld =
|
||||
!!selectedItem &&
|
||||
parentalLock.isStalkerCategoryLocked(
|
||||
playlistId,
|
||||
contentType,
|
||||
itemCategoryId
|
||||
);
|
||||
if (!categoryWithheld && !itemWithheld) {
|
||||
return;
|
||||
}
|
||||
stalkerStore.clearSelectedItem();
|
||||
if (categoryWithheld) {
|
||||
stalkerStore.setSelectedCategory(null);
|
||||
}
|
||||
const match = STALKER_ROUTE.exec(router.url);
|
||||
if (match && match[1] === playlistId) {
|
||||
void router.navigate(['/workspace', 'stalker', match[1], match[2]]);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,11 @@ export class PlaybackKeepAwakeService {
|
||||
this.sync();
|
||||
};
|
||||
|
||||
/** Whether any tracked `<video>` is currently playing. */
|
||||
hasPlayingVideo(): boolean {
|
||||
return this.playingVideos.size > 0;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started) {
|
||||
return;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
normalizeEmbeddedMpvExtraOptions,
|
||||
normalizeExternalPlayerArguments,
|
||||
normalizeEpgOffsetMinutes,
|
||||
normalizeParentalLockRelockMinutes,
|
||||
normalizeStartupWindowMode,
|
||||
Settings,
|
||||
StartupBehavior,
|
||||
@@ -228,6 +229,12 @@ export function createSettingsFromFormValue(
|
||||
enabled: value.tmdb?.enabled ?? DEFAULT_TMDB_SETTINGS.enabled,
|
||||
apiKey: value.tmdb?.apiKey?.trim() ?? '',
|
||||
},
|
||||
// Not on the form: the parental lock section applies its changes
|
||||
// immediately (they gate on the PIN), so Save must not undo them.
|
||||
parentalLockEnabled: currentSettings.parentalLockEnabled === true,
|
||||
parentalLockRelockMinutes: normalizeParentalLockRelockMinutes(
|
||||
currentSettings.parentalLockRelockMinutes
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
'dashboard',
|
||||
'remote-control',
|
||||
'tmdb',
|
||||
'parental',
|
||||
'backup',
|
||||
'reset',
|
||||
'about',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
PARENTAL_LOCK_RELOCK_MINUTES_OPTIONS,
|
||||
ParentalLockRelockMinutes,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsSnackbarService } from './settings-snackbar.service';
|
||||
|
||||
/**
|
||||
* Settings → Parental lock. Every control here applies immediately: the
|
||||
* enable toggle and the PIN actions gate on the PIN dialog, so staging them
|
||||
* in the shared dirty form (Save/Discard) would make no sense — a
|
||||
* "discarded" PIN would already have been typed.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SettingsParentalLockFacade {
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly snackbar = inject(SettingsSnackbarService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly enabled = this.parentalLock.enabled;
|
||||
readonly unlocked = this.parentalLock.unlocked;
|
||||
readonly hasPin = this.parentalLock.hasPin;
|
||||
readonly relockMinutes = this.parentalLock.relockMinutes;
|
||||
readonly relockOptions = PARENTAL_LOCK_RELOCK_MINUTES_OPTIONS;
|
||||
readonly busy = signal(false);
|
||||
readonly canLockNow = computed(() => this.enabled() && this.unlocked());
|
||||
|
||||
constructor() {
|
||||
void this.parentalLock.initialize();
|
||||
}
|
||||
|
||||
async toggleEnabled(enabled: boolean): Promise<void> {
|
||||
await this.run(async () => {
|
||||
if (enabled) {
|
||||
if (await this.parentalLock.setupPin()) {
|
||||
this.notify('SETTINGS.PARENTAL_LOCK.ENABLED_TOAST');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (await this.parentalLock.disable()) {
|
||||
this.notify('SETTINGS.PARENTAL_LOCK.DISABLED_TOAST');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async changePin(): Promise<void> {
|
||||
await this.run(async () => {
|
||||
if (await this.parentalLock.changePin()) {
|
||||
this.notify('SETTINGS.PARENTAL_LOCK.PIN_CHANGED_TOAST');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setRelockMinutes(minutes: ParentalLockRelockMinutes): Promise<void> {
|
||||
await this.run(async () => {
|
||||
if (!(await this.parentalLock.setRelockMinutes(minutes))) {
|
||||
this.snackbar.storageFailure('save');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
lockNow(): void {
|
||||
this.parentalLock.lock();
|
||||
this.notify('SETTINGS.PARENTAL_LOCK.LOCKED_TOAST');
|
||||
}
|
||||
|
||||
async unlock(): Promise<void> {
|
||||
await this.run(async () => {
|
||||
await this.parentalLock.requestUnlock();
|
||||
});
|
||||
}
|
||||
|
||||
private notify(key: string): void {
|
||||
this.snackbar.open(this.translate.instant(key));
|
||||
}
|
||||
|
||||
private async run(action: () => Promise<unknown>): Promise<void> {
|
||||
if (this.busy()) {
|
||||
return;
|
||||
}
|
||||
this.busy.set(true);
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<section class="settings-group" id="parental">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>family_restroom</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-enable">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
data-test-id="parental-lock-enabled"
|
||||
[checked]="enabled()"
|
||||
[disabled]="busy()"
|
||||
(change)="onToggleEnabled($event)"
|
||||
[attr.aria-label]="'SETTINGS.PARENTAL_LOCK.ENABLE' | translate"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (enabled()) {
|
||||
<div class="setting-item" data-setting-id="parental-lock-status">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
(unlocked()
|
||||
? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED'
|
||||
: 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED'
|
||||
) | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
@if (unlocked()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-lock-now"
|
||||
[disabled]="busy()"
|
||||
(click)="lockNow.emit()"
|
||||
>
|
||||
<mat-icon>lock</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.LOCK_NOW' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-unlock"
|
||||
[disabled]="busy()"
|
||||
(click)="unlock.emit()"
|
||||
>
|
||||
<mat-icon>lock_open</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.UNLOCK' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-relock">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION' | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.PARENTAL_LOCK.RELOCK' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
data-test-id="parental-lock-relock"
|
||||
[value]="relockMinutes()"
|
||||
[disabled]="busy() || !unlocked()"
|
||||
(selectionChange)="
|
||||
relockMinutesChanged.emit($event.value)
|
||||
"
|
||||
>
|
||||
@for (option of relockOptions(); track option) {
|
||||
<mat-option [value]="option">
|
||||
{{
|
||||
relockLabelKey(option)
|
||||
| translate: { minutes: option }
|
||||
}}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-change-pin">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-change-pin"
|
||||
[disabled]="busy() || !hasPin()"
|
||||
(click)="changePin.emit()"
|
||||
>
|
||||
<mat-icon>password</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-how-to">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SettingsParentalLockSectionComponent } from './settings-parental-section.component';
|
||||
|
||||
describe('SettingsParentalLockSectionComponent', () => {
|
||||
it('keeps the enable switch on the saved state until the PIN action succeeds', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SettingsParentalLockSectionComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(
|
||||
SettingsParentalLockSectionComponent
|
||||
);
|
||||
fixture.componentRef.setInput('enabled', false);
|
||||
fixture.componentRef.setInput('unlocked', false);
|
||||
fixture.componentRef.setInput('hasPin', false);
|
||||
fixture.componentRef.setInput('relockMinutes', 15);
|
||||
fixture.componentRef.setInput('relockOptions', [0, 15]);
|
||||
fixture.detectChanges();
|
||||
const requested = jest.fn();
|
||||
fixture.componentInstance.toggleEnabled.subscribe(requested);
|
||||
const toggle = fixture.debugElement.query(By.directive(MatSlideToggle))
|
||||
.componentInstance as MatSlideToggle;
|
||||
const button = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="parental-lock-enabled"] button'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
// The set-PIN prompt is then cancelled: `enabled` never changes.
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(requested).toHaveBeenCalledWith(true);
|
||||
expect(toggle.checked).toBe(false);
|
||||
|
||||
// The PIN was set: the switch follows the saved state.
|
||||
fixture.componentRef.setInput('enabled', true);
|
||||
fixture.detectChanges();
|
||||
expect(toggle.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import {
|
||||
MatSlideToggleChange,
|
||||
MatSlideToggleModule,
|
||||
} from '@angular/material/slide-toggle';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-parental-lock-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggleModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-parental-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsParentalLockSectionComponent {
|
||||
readonly enabled = input.required<boolean>();
|
||||
readonly unlocked = input.required<boolean>();
|
||||
readonly hasPin = input.required<boolean>();
|
||||
readonly relockMinutes = input.required<ParentalLockRelockMinutes>();
|
||||
readonly relockOptions =
|
||||
input.required<readonly ParentalLockRelockMinutes[]>();
|
||||
readonly busy = input(false);
|
||||
|
||||
readonly toggleEnabled = output<boolean>();
|
||||
readonly changePin = output<void>();
|
||||
readonly relockMinutesChanged = output<ParentalLockRelockMinutes>();
|
||||
readonly lockNow = output<void>();
|
||||
readonly unlock = output<void>();
|
||||
|
||||
/**
|
||||
* The switch only REQUESTS the change: enabling and disabling both go
|
||||
* through the PIN, which may be cancelled. It is put back to the saved
|
||||
* state at once and follows `enabled()` when the action succeeds;
|
||||
* otherwise the slide toggle would keep showing the state it flipped to
|
||||
* (`[checked]` is not re-applied while the bound value is unchanged).
|
||||
*/
|
||||
onToggleEnabled(event: MatSlideToggleChange): void {
|
||||
event.source.checked = this.enabled();
|
||||
this.toggleEnabled.emit(event.checked);
|
||||
}
|
||||
|
||||
relockLabelKey(minutes: ParentalLockRelockMinutes): string {
|
||||
return minutes === 0
|
||||
? 'SETTINGS.PARENTAL_LOCK.RELOCK_NEVER'
|
||||
: 'SETTINGS.PARENTAL_LOCK.RELOCK_MINUTES';
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
selectAllPlaylistsMeta,
|
||||
selectIsEpgAvailable,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
ParentalLockService,
|
||||
DatabaseService,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
@@ -59,6 +63,9 @@ describe('SettingsPlaylistResetFacade', () => {
|
||||
MockProvider(PlaylistsService, {
|
||||
removeAll: jest.fn().mockReturnValue(of(undefined)),
|
||||
}),
|
||||
MockProvider(ParentalLockService, {
|
||||
clearAllLocks: jest.fn().mockResolvedValue(true),
|
||||
}),
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{ selector: selectAllPlaylistsMeta, value: [] },
|
||||
@@ -218,6 +225,9 @@ describe('SettingsPlaylistResetFacade', () => {
|
||||
|
||||
expect(playlistsService.removeAll).toHaveBeenCalled();
|
||||
expect(databaseService.deleteAllPlaylists).not.toHaveBeenCalled();
|
||||
expect(
|
||||
TestBed.inject(ParentalLockService).clearAllLocks
|
||||
).toHaveBeenCalled();
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
PlaylistActions.removeAllPlaylists()
|
||||
);
|
||||
@@ -235,6 +245,10 @@ describe('SettingsPlaylistResetFacade', () => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(facade.isRemovingAllPlaylists()).toBe(false);
|
||||
// Nothing was deleted, so the locks stay.
|
||||
expect(
|
||||
TestBed.inject(ParentalLockService).clearAllLocks
|
||||
).not.toHaveBeenCalled();
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'SETTINGS.PLAYLISTS_REMOVE_FAILED',
|
||||
undefined,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { firstValueFrom, take } from 'rxjs';
|
||||
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
ParentalLockService,
|
||||
DatabaseService,
|
||||
DbOperationEvent,
|
||||
PlaylistsService,
|
||||
@@ -24,6 +25,7 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
|
||||
@Injectable()
|
||||
export class SettingsPlaylistResetFacade {
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly matDialog = inject(MatDialog);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
@@ -107,6 +109,12 @@ export class SettingsPlaylistResetFacade {
|
||||
if (!deleted) {
|
||||
throw new Error('Delete all playlists returned success=false');
|
||||
}
|
||||
// The playlists are gone; their locks must not linger in the
|
||||
// lock store (the deletion itself already succeeded, so a failed
|
||||
// clear is only logged).
|
||||
if (!(await this.parentalLock.clearAllLocks())) {
|
||||
console.warn('Clearing the parental locks failed.');
|
||||
}
|
||||
|
||||
this.store.dispatch(PlaylistActions.removeAllPlaylists());
|
||||
this.settingsSnackbar.open(
|
||||
|
||||
@@ -379,6 +379,10 @@ describe('SettingsComponent form', () => {
|
||||
...component.settingsForm.value,
|
||||
trustedPrivateNetworkEpgUrls: [],
|
||||
trustedInsecureTlsHosts: [],
|
||||
// Parental lock settings are not on the form; Save
|
||||
// carries the current values through unchanged.
|
||||
parentalLockEnabled: false,
|
||||
parentalLockRelockMinutes: 15,
|
||||
},
|
||||
{ retryEpgCleanup: false }
|
||||
);
|
||||
@@ -386,6 +390,8 @@ describe('SettingsComponent form', () => {
|
||||
...component.settingsForm.value,
|
||||
trustedPrivateNetworkEpgUrls: [],
|
||||
trustedInsecureTlsHosts: [],
|
||||
parentalLockEnabled: false,
|
||||
parentalLockRelockMinutes: 15,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -78,6 +78,23 @@
|
||||
@case ('tmdb') {
|
||||
<app-settings-tmdb-section [form]="settingsForm" />
|
||||
}
|
||||
@case ('parental') {
|
||||
<app-settings-parental-lock-section
|
||||
[enabled]="parentalLock.enabled()"
|
||||
[unlocked]="parentalLock.unlocked()"
|
||||
[hasPin]="parentalLock.hasPin()"
|
||||
[relockMinutes]="parentalLock.relockMinutes()"
|
||||
[relockOptions]="parentalLock.relockOptions"
|
||||
[busy]="parentalLock.busy()"
|
||||
(toggleEnabled)="parentalLock.toggleEnabled($event)"
|
||||
(changePin)="parentalLock.changePin()"
|
||||
(relockMinutesChanged)="
|
||||
parentalLock.setRelockMinutes($event)
|
||||
"
|
||||
(lockNow)="parentalLock.lockNow()"
|
||||
(unlock)="parentalLock.unlock()"
|
||||
/>
|
||||
}
|
||||
@case ('backup') {
|
||||
<app-settings-backup-section
|
||||
[isPwa]="isPwa"
|
||||
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
SETTINGS_UPDATE_CHANNEL_OPTIONS,
|
||||
SETTINGS_THEME_OPTIONS,
|
||||
} from './settings-options';
|
||||
import { SettingsParentalLockFacade } from './settings-parental-lock.facade';
|
||||
import { SettingsParentalLockSectionComponent } from './settings-parental-section.component';
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
|
||||
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
|
||||
@@ -93,6 +95,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
|
||||
SettingsDashboardSectionComponent,
|
||||
SettingsEpgSectionComponent,
|
||||
SettingsGeneralSectionComponent,
|
||||
SettingsParentalLockSectionComponent,
|
||||
SettingsPlaybackSectionComponent,
|
||||
SettingsRemoteControlSectionComponent,
|
||||
SettingsResetSectionComponent,
|
||||
@@ -107,6 +110,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
|
||||
SettingsEmbeddedMpvFacade,
|
||||
SettingsEpgFacade,
|
||||
SettingsFormFacade,
|
||||
SettingsParentalLockFacade,
|
||||
SettingsPlaylistResetFacade,
|
||||
SettingsRemoteControlFacade,
|
||||
SettingsSearchFacade,
|
||||
@@ -122,6 +126,7 @@ export class SettingsComponent
|
||||
readonly embeddedMpv = inject(SettingsEmbeddedMpvFacade);
|
||||
readonly epg = inject(SettingsEpgFacade);
|
||||
readonly form = inject(SettingsFormFacade);
|
||||
readonly parentalLock = inject(SettingsParentalLockFacade);
|
||||
readonly playlistReset = inject(SettingsPlaylistResetFacade);
|
||||
readonly remoteControl = inject(SettingsRemoteControlFacade);
|
||||
readonly search = inject(SettingsSearchFacade);
|
||||
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "إعادة الاتصال بالبث المنقطع تلقائيًا",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "عند انقطاع بث قيد التشغيل، يعيد المشغّل المدمج تحميله بفواصل زمنية متزايدة، حتى 6 محاولات، قبل أن يتوقف. أما البث الذي لم يبدأ أصلًا فلا يزال يحتاج إلى إعادة المحاولة يدويًا.",
|
||||
"VOD_AUTO_FAILOVER": "التبديل التلقائي للمصدر عند فشل التشغيل",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "داكن",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "تبديل المشغل إلى {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "تعيين {{name}} كمشغل الفيديو النشط",
|
||||
"SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "جارٍ تحديث قائمة التشغيل",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "جارٍ الحفاظ على بيانات مكتبتك...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "عاود الاتصال أوتوماتيكياً بالبث اللي تقطع",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "ملي شي بث خدام كيتقطع، المشغل المدمج كيعاود يشرجو بفواصل كتزيد شوية بشوية، حتى 6 محاولات، قبل ما يوقف. البث اللي عمرو ما بدا كيبقى محتاج تحاول مرة أخرى يدوياً.",
|
||||
"VOD_AUTO_FAILOVER": "بدل المصدر أوتوماتيكياً عند خطأ فالتشغيل",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "داكن",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "بدل المشغل ل{{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "حدد {{name}} كمشغل الفيديو النشط",
|
||||
"SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "عرض بيانات حساب بوابة Stalker",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "كنحدّثو البلايليست",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "كنحافظو على معطيات المكتبة ديالك...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Аўтаматычна перападключацца да абарваных патокаў",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Калі паток, які прайграецца, абрываецца, убудаваны прайгравальнік перазагружае яго з усё большымі паўзамі, да 6 спроб, перш чым спыніцца. Паток, які так і не запусціўся, па-ранейшаму патрабуе ручнога «Паўтарыць».",
|
||||
"VOD_AUTO_FAILOVER": "Аўтапераключэнне крыніцы пры памылцы прайгравання",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Цёмная",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Пераключыць прайгравальнік на {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Усталяваць {{name}} як актыўны відэапрайгравальнік",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Прайгравальнік усталяваны: {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Паказаць даныя акаўнта Stalker-партала"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Паказаць даныя акаўнта Stalker-партала",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Абнаўленне плэйліста",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Захаванне даных вашай бібліятэкі...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Unterbrochene Streams automatisch neu verbinden",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Wenn ein laufender Stream unterbrochen wird, lädt der eingebettete Player ihn in bis zu 6 Versuchen mit immer längeren Wartezeiten neu, bevor er aufgibt. Ein Stream, der gar nicht erst gestartet ist, erfordert weiterhin ein manuelles „Erneut versuchen“.",
|
||||
"VOD_AUTO_FAILOVER": "Quelle bei Wiedergabefehler automatisch wechseln",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann.",
|
||||
"NAV_PARENTAL": "Kindersicherung",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Kindersicherung",
|
||||
"DESCRIPTION": "Verbirgt ausgewählte Kategorien hinter einer PIN. Gesperrte Kategorien, ihre Sender und Titel werden aus der gesamten App entfernt, bis die PIN eingegeben wird.",
|
||||
"ENABLE": "Kindersicherung aktivieren",
|
||||
"ENABLE_DESCRIPTION": "Beim Aktivieren wird eine neue PIN abgefragt. Beim Deaktivieren die aktuelle PIN; die Sperren bleiben erhalten.",
|
||||
"STATUS": "Aktueller Zustand",
|
||||
"STATUS_LOCKED": "Gesperrt – gesperrte Kategorien sind verborgen.",
|
||||
"STATUS_UNLOCKED": "Entsperrt – gesperrte Kategorien sind sichtbar, bis die App wieder sperrt.",
|
||||
"LOCK_NOW": "Jetzt sperren",
|
||||
"UNLOCK": "Entsperren",
|
||||
"RELOCK": "Erneut sperren nach",
|
||||
"RELOCK_DESCRIPTION": "Minuten ohne Interaktion, nach denen sich die App wieder sperrt. Wiedergabe zählt als Interaktion. Ein Neustart sperrt immer.",
|
||||
"RELOCK_NEVER": "Nur beim Neustart der App",
|
||||
"RELOCK_MINUTES": "{{minutes}} Minuten Inaktivität",
|
||||
"CHANGE_PIN": "PIN ändern",
|
||||
"CHANGE_PIN_DESCRIPTION": "Fragt zuerst die aktuelle PIN ab.",
|
||||
"HOW_TO_LOCK": "Was gesperrt wird",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Öffne eine Quelle und wähle „Kategorien verwalten“ (das Einstellungssymbol über der Kategorienliste; in einer M3U-Playlist „Gruppen verwalten“): jede Zeile hat einen Schloss-Schalter, und Stalker bietet „Erwachsene (18+) sperren“ für vom Portal markierte Genres. Rechtsklick auf eine Kategorie oder Gruppe für schnelles Sperren / Entsperren.",
|
||||
"DISCLAIMER": "Das ist eine Kindersicherung, kein Sicherheitsmerkmal: Die Datendateien der App sind für jeden mit Zugriff auf diesen Computer lesbar.",
|
||||
"ENABLED_TOAST": "Kindersicherung aktiviert",
|
||||
"DISABLED_TOAST": "Kindersicherung deaktiviert",
|
||||
"PIN_CHANGED_TOAST": "PIN geändert",
|
||||
"LOCKED_TOAST": "Gesperrt"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dunkel",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Player auf {{name}} wechseln",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "{{name}} als aktiven Videoplayer festlegen",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Player auf {{name}} festgelegt",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Kontodaten des Stalker-Portals anzeigen"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Kontodaten des Stalker-Portals anzeigen",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Jetzt sperren",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Kindergesicherte Kategorien wieder verbergen",
|
||||
"PARENTAL_UNLOCK_LABEL": "Kindersicherungs-PIN eingeben",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Kindergesicherte Kategorien anzeigen"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Playlist wird aktualisiert",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Deine Bibliotheksdaten werden gesichert...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Kindersicherungs-PIN eingeben",
|
||||
"UNLOCK_DESCRIPTION": "Gesperrte Kategorien bleiben verborgen, bis die PIN eingegeben wird.",
|
||||
"SET_TITLE": "Kindersicherungs-PIN festlegen",
|
||||
"SET_DESCRIPTION": "Wähle eine PIN, die zum Anzeigen gesperrter Kategorien nötig ist.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "PIN wiederholen",
|
||||
"PIN_HINT": "{{min}} bis {{max}} Ziffern",
|
||||
"WRONG_PIN": "Falsche PIN. Bitte erneut versuchen.",
|
||||
"MISMATCH": "Die beiden PINs stimmen nicht überein.",
|
||||
"COOLDOWN": "Zu viele Versuche. Warte 30 Sekunden.",
|
||||
"NO_RECOVERY": "Eine vergessene PIN kann nicht wiederhergestellt werden – nur das Zurücksetzen der App-Daten entfernt sie.",
|
||||
"SAVE": "PIN speichern",
|
||||
"UNLOCK": "Entsperren",
|
||||
"CONFIRM_TITLE": "Kindersicherungs-PIN bestätigen",
|
||||
"CONFIRM_DESCRIPTION": "Gib die aktuelle PIN ein, um diese Einstellung zu ändern."
|
||||
},
|
||||
"LOCKED_COUNT": "Gesperrt",
|
||||
"LOCK_CATEGORY": "Kategorie sperren",
|
||||
"UNLOCK_CATEGORY": "Kategorie entsperren",
|
||||
"LOCK_GROUP": "Gruppe sperren",
|
||||
"UNLOCK_GROUP": "Gruppe entsperren",
|
||||
"MANAGE_LOCKS": "Kategorien sperren",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Gesperrte Kategorien und ihre Sender verschwinden aus der gesamten App, bis die Kindersicherungs-PIN eingegeben wird.",
|
||||
"LOCK_CENSORED": "18+ sperren",
|
||||
"UNLOCK_ALL": "Alle entsperren",
|
||||
"CENSORED_HINT": "Das Portal kennzeichnet diese Kategorie als Inhalt für Erwachsene",
|
||||
"SAVE_FAILED": "Die Kategoriesperren konnten nicht gespeichert werden",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} gesperrt",
|
||||
"UNLOCK_TO_SHOW": "PIN eingeben zum Anzeigen",
|
||||
"HEADER_LOCK": "Jetzt sperren (Kindersicherung)",
|
||||
"HEADER_UNLOCK": "Kindersicherungs-PIN eingeben"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Αυτόματη επανασύνδεση σε ροές που διακόπηκαν",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Όταν μια ροή που αναπαράγεται διακοπεί, το ενσωματωμένο πρόγραμμα αναπαραγωγής τη φορτώνει ξανά με αυξανόμενες καθυστερήσεις, έως 6 προσπάθειες, πριν τα παρατήσει. Μια ροή που δεν ξεκίνησε ποτέ εξακολουθεί να χρειάζεται χειροκίνητη Επανάληψη.",
|
||||
"VOD_AUTO_FAILOVER": "Αυτόματη αλλαγή πηγής σε σφάλμα αναπαραγωγής",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Σκούρο",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Εναλλαγή προγράμματος αναπαραγωγής σε {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Ορισμός του {{name}} ως ενεργό πρόγραμμα αναπαραγωγής βίντεο",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Εμφάνιση στοιχείων λογαριασμού της πύλης Stalker"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Εμφάνιση στοιχείων λογαριασμού της πύλης Stalker",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Ανανέωση λίστας αναπαραγωγής",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Διατήρηση των δεδομένων της βιβλιοθήκης σας...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconnect dropped streams automatically",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "When a playing stream drops, the embedded player reloads it with increasing delays, up to 6 attempts, before giving up. A stream that never started still needs a manual Retry.",
|
||||
"VOD_AUTO_FAILOVER": "Auto-switch source on playback error",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dark",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Switch player to {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Set {{name}} as the active video player",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Show Stalker portal account details"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Show Stalker portal account details",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Refreshing playlist",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Preserving your library data...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconectar automáticamente los streams interrumpidos",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Cuando un stream en reproducción se interrumpe, el reproductor integrado lo vuelve a cargar con intervalos cada vez más largos, hasta 6 intentos, antes de desistir. Un stream que nunca llegó a iniciarse sigue necesitando un Reintentar manual.",
|
||||
"VOD_AUTO_FAILOVER": "Cambiar de fuente automáticamente al fallar la reproducción",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Oscuro",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Cambiar reproductor a {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Establecer {{name}} como reproductor de vídeo activo",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Reproductor establecido a {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar los datos de la cuenta del portal Stalker"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar los datos de la cuenta del portal Stalker",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Actualizando lista de reproducción",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Conservando los datos de tu biblioteca...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconnecter automatiquement les flux interrompus",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Lorsqu'un flux en cours de lecture est interrompu, le lecteur intégré le recharge avec des délais croissants, jusqu'à 6 tentatives, avant d'abandonner. Un flux qui n'a jamais démarré doit toujours être relancé manuellement avec « Réessayer ».",
|
||||
"VOD_AUTO_FAILOVER": "Changer de source automatiquement en cas d'erreur de lecture",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Sombre",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Basculer le lecteur sur {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Définir {{name}} comme lecteur vidéo actif",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Lecteur défini sur {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Afficher les informations du compte du portail Stalker"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Afficher les informations du compte du portail Stalker",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Actualisation de la playlist",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Préservation des données de votre bibliothèque...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Megszakadt adatfolyamok automatikus újracsatlakoztatása",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Ha egy lejátszás alatt álló adatfolyam megszakad, a beágyazott lejátszó egyre hosszabb várakozással, legfeljebb 6 próbálkozással tölti be újra, mielőtt feladná. Az el sem indult adatfolyamhoz továbbra is kézi újrapróbálkozás szükséges.",
|
||||
"VOD_AUTO_FAILOVER": "Automatikus forrásváltás lejátszási hiba esetén",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Sötét",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Lejátszóváltás: {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "{{name}} beállítása aktív videólejátszóként",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Aktív lejátszó: {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "A Stalker portál fiókadatainak megjelenítése"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "A Stalker portál fiókadatainak megjelenítése",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Lejátszási lista frissítése",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "A médiatár adatainak megőrzése…",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Riconnetti automaticamente gli stream interrotti",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Quando uno stream in riproduzione si interrompe, il lettore integrato lo ricarica a intervalli crescenti, fino a 6 tentativi, prima di rinunciare. Per uno stream che non è mai partito è comunque necessario premere Riprova manualmente.",
|
||||
"VOD_AUTO_FAILOVER": "Cambia sorgente automaticamente in caso di errore di riproduzione",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Scuro",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Passa al lettore {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Imposta {{name}} come lettore video attivo",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostra i dati dell’account del portale Stalker"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostra i dati dell’account del portale Stalker",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Aggiornamento playlist",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Conservazione dei dati della tua libreria...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "切断されたストリームに自動的に再接続",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "再生中のストリームが切断されると、埋め込みプレーヤーは間隔を延ばしながら最大 6 回まで再読み込みを試み、その後あきらめます。一度も開始しなかったストリームには、引き続き手動の再試行が必要です。",
|
||||
"VOD_AUTO_FAILOVER": "再生エラー時にソースを自動切り替え",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。"
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "ダーク",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "プレーヤーを{{name}}に切り替え",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "{{name}}をアクティブなビデオプレーヤーに設定",
|
||||
"SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalkerポータルのアカウント情報を表示"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalkerポータルのアカウント情報を表示",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "プレイリストを更新中",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "ライブラリデータを保持しています...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "끊긴 스트림 자동 재연결",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "재생 중인 스트림이 끊기면 내장 플레이어가 점점 길어지는 간격으로 최대 6회까지 다시 불러온 뒤 포기합니다. 한 번도 시작되지 않은 스트림은 여전히 수동으로 다시 시도해야 합니다.",
|
||||
"VOD_AUTO_FAILOVER": "재생 오류 시 소스 자동 전환",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "어둡게",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "플레이어를 {{name}}(으)로 전환",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "{{name}}을(를) 활성 비디오 플레이어로 설정",
|
||||
"SWITCH_PLAYER_FEEDBACK": "플레이어가 {{name}}(으)로 설정되었습니다",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker 포털 계정 정보 표시"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker 포털 계정 정보 표시",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "재생목록 새로 고침 중",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "라이브러리 데이터 보존 중...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Weggevallen streams automatisch opnieuw verbinden",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Wanneer een stream tijdens het afspelen wegvalt, laadt de ingebedde speler deze opnieuw met steeds langere tussenpozen, tot maximaal 6 pogingen, voordat hij het opgeeft. Voor een stream die nooit is gestart, blijft handmatig Opnieuw proberen nodig.",
|
||||
"VOD_AUTO_FAILOVER": "Automatisch van bron wisselen bij een afspeelfout",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Donker",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Speler wisselen naar {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Stel {{name}} in als de actieve videospeler",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Speler ingesteld op {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Accountgegevens van het Stalker-portaal tonen"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Accountgegevens van het Stalker-portaal tonen",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Afspeellijst vernieuwen",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Uw bibliotheekgegevens behouden...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Automatycznie wznawiaj przerwane strumienie",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Gdy odtwarzany strumień zostanie przerwany, wbudowany odtwarzacz wczytuje go ponownie z rosnącymi odstępami, maksymalnie 6 prób, zanim się podda. Strumień, który w ogóle się nie uruchomił, nadal wymaga ręcznego użycia „Spróbuj ponownie”.",
|
||||
"VOD_AUTO_FAILOVER": "Automatyczna zmiana źródła przy błędzie odtwarzania",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Ciemny",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Przełącz odtwarzacz na {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Ustaw {{name}} jako aktywny odtwarzacz wideo",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Odtwarzacz ustawiony na {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Pokaż dane konta portalu Stalker"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Pokaż dane konta portalu Stalker",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Odświeżanie listy odtwarzania",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Zachowywanie danych biblioteki...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Reconectar streams interrompidos automaticamente",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Quando um stream em reprodução é interrompido, o reprodutor integrado o recarrega com intervalos crescentes, em até 6 tentativas, antes de desistir. Um stream que nunca chegou a iniciar ainda precisa de um \"Tentar novamente\" manual.",
|
||||
"VOD_AUTO_FAILOVER": "Trocar de fonte automaticamente ao falhar a reprodução",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Escuro",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Alternar reprodutor para {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Definir {{name}} como reprodutor de vídeo ativo",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Reprodutor definido como {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar dados da conta do portal Stalker"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Mostrar dados da conta do portal Stalker",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Atualizando playlist",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Preservando os dados da sua biblioteca...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Автоматически переподключаться при обрыве потока",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Если поток обрывается во время воспроизведения, встроенный плеер перезагружает его с возрастающими задержками (до 6 попыток), после чего прекращает переподключение. Для потока, который так и не запустился, по-прежнему нужно нажать «Повторить» вручную.",
|
||||
"VOD_AUTO_FAILOVER": "Авто-переключение источника при ошибке воспроизведения",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка.",
|
||||
"NAV_PARENTAL": "Родительский контроль",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Родительский контроль",
|
||||
"DESCRIPTION": "Скрывайте выбранные категории за PIN-кодом. Заблокированные категории, их каналы и фильмы убираются из всего приложения, пока не введён PIN.",
|
||||
"ENABLE": "Включить родительский контроль",
|
||||
"ENABLE_DESCRIPTION": "При включении будет запрошен новый PIN. При выключении — текущий PIN; блокировки сохраняются на будущее.",
|
||||
"STATUS": "Текущее состояние",
|
||||
"STATUS_LOCKED": "Заблокировано — заблокированные категории скрыты.",
|
||||
"STATUS_UNLOCKED": "Разблокировано — заблокированные категории видны до следующей блокировки.",
|
||||
"LOCK_NOW": "Заблокировать сейчас",
|
||||
"UNLOCK": "Разблокировать",
|
||||
"RELOCK": "Блокировать снова через",
|
||||
"RELOCK_DESCRIPTION": "Минуты без действий, после которых приложение блокируется само. Воспроизведение считается действием. Перезапуск приложения всегда блокирует.",
|
||||
"RELOCK_NEVER": "Только при перезапуске приложения",
|
||||
"RELOCK_MINUTES": "{{minutes}} минут бездействия",
|
||||
"CHANGE_PIN": "Сменить PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Сначала запрашивается текущий PIN.",
|
||||
"HOW_TO_LOCK": "Что блокировать",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Откройте источник и нажмите «Управлять категориями» (иконка настройки над списком категорий; в плейлисте M3U — «Управление группами»): у каждой строки есть переключатель замка, а для Stalker доступно «Заблокировать 18+» для жанров, помеченных порталом. Правый клик по категории или группе — быстрое «Заблокировать / Разблокировать».",
|
||||
"DISCLAIMER": "Это детская блокировка, а не защита: файлы данных приложения доступны любому, у кого есть доступ к этому компьютеру.",
|
||||
"ENABLED_TOAST": "Родительский контроль включён",
|
||||
"DISABLED_TOAST": "Родительский контроль выключен",
|
||||
"PIN_CHANGED_TOAST": "PIN изменён",
|
||||
"LOCKED_TOAST": "Заблокировано"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Тёмная",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Переключить плеер на {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Сделать {{name}} активным видеоплеером",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Плеер переключен на {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Показать данные аккаунта Stalker-портала"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Показать данные аккаунта Stalker-портала",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Заблокировать сейчас",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Снова скрыть категории под родительским контролем",
|
||||
"PARENTAL_UNLOCK_LABEL": "Ввести родительский PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Показать категории под родительским контролем"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Обновление плейлиста",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Сохраняем данные вашей библиотеки...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Не удалось удалить — выберите для повтора"
|
||||
},
|
||||
"DELETE_PROGRESS": "Обработано {{done}} из {{total}} выбранных источников"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Введите родительский PIN",
|
||||
"UNLOCK_DESCRIPTION": "Заблокированные категории скрыты, пока не введён PIN.",
|
||||
"SET_TITLE": "Задайте родительский PIN",
|
||||
"SET_DESCRIPTION": "Придумайте PIN, который потребуется для показа заблокированных категорий.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Повторите PIN",
|
||||
"PIN_HINT": "от {{min}} до {{max}} цифр",
|
||||
"WRONG_PIN": "Неверный PIN. Попробуйте ещё раз.",
|
||||
"MISMATCH": "PIN-коды не совпадают.",
|
||||
"COOLDOWN": "Слишком много попыток. Подождите 30 секунд.",
|
||||
"NO_RECOVERY": "Забытый PIN восстановить нельзя — его удаляет только сброс данных приложения.",
|
||||
"SAVE": "Сохранить PIN",
|
||||
"UNLOCK": "Разблокировать",
|
||||
"CONFIRM_TITLE": "Подтвердите родительский PIN",
|
||||
"CONFIRM_DESCRIPTION": "Введите текущий PIN, чтобы изменить эту настройку."
|
||||
},
|
||||
"LOCKED_COUNT": "Заблокировано",
|
||||
"LOCK_CATEGORY": "Заблокировать категорию",
|
||||
"UNLOCK_CATEGORY": "Разблокировать категорию",
|
||||
"LOCK_GROUP": "Заблокировать группу",
|
||||
"UNLOCK_GROUP": "Разблокировать группу",
|
||||
"MANAGE_LOCKS": "Заблокировать категории",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Заблокированные категории и их каналы исчезают из всего приложения, пока не введён родительский PIN.",
|
||||
"LOCK_CENSORED": "Заблокировать 18+",
|
||||
"UNLOCK_ALL": "Разблокировать все",
|
||||
"CENSORED_HINT": "Портал помечает эту категорию как контент для взрослых",
|
||||
"SAVE_FAILED": "Не удалось сохранить блокировки категорий",
|
||||
"LOCKED_CATEGORIES_ROW": "Заблокировано: {{count}}",
|
||||
"UNLOCK_TO_SHOW": "Введите PIN, чтобы показать",
|
||||
"HEADER_LOCK": "Заблокировать сейчас (родительский контроль)",
|
||||
"HEADER_UNLOCK": "Введите родительский PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "Kesilen yayınlara otomatik yeniden bağlan",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "Oynatılan bir yayın kesildiğinde gömülü oynatıcı, vazgeçmeden önce yayını giderek artan gecikmelerle en fazla 6 kez yeniden yüklemeyi dener. Hiç başlamamış bir yayın için yine de Tekrar dene’ye el ile basmanız gerekir.",
|
||||
"VOD_AUTO_FAILOVER": "Oynatma hatasında kaynağı otomatik değiştir",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir."
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir.",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Koyu",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "Oynatıcıyı {{name}} olarak değiştir",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "{{name}} oynatıcısını etkin video oynatıcı olarak ayarla",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Oynatıcı {{name}} olarak ayarlandı",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker portalı hesap bilgilerini göster"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "Stalker portalı hesap bilgilerini göster",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "Oynatma listesi yenileniyor",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "Kitaplık verileriniz korunuyor...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "自动重连中断的流",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "正在播放的流中断时,嵌入式播放器会以逐渐增加的间隔重新加载,最多尝试 6 次后放弃。从未成功启动的流仍需手动重试。",
|
||||
"VOD_AUTO_FAILOVER": "播放出错时自动切换播放源",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。"
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "切换播放器为 {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "将 {{name}} 设为当前视频播放器",
|
||||
"SWITCH_PLAYER_FEEDBACK": "播放器已设为 {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "显示 Stalker 门户账户信息"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "显示 Stalker 门户账户信息",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "正在刷新播放列表",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "正在保留您的媒体库数据…",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,32 @@
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT": "自動重新連線中斷的串流",
|
||||
"EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION": "播放中的串流中斷時,內嵌播放器會以逐漸拉長的間隔重新載入,最多嘗試 6 次後放棄。從未成功啟動的串流仍需手動重試。",
|
||||
"VOD_AUTO_FAILOVER": "播放發生錯誤時自動切換來源",
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。"
|
||||
"VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。",
|
||||
"NAV_PARENTAL": "Parental lock",
|
||||
"PARENTAL_LOCK": {
|
||||
"TITLE": "Parental lock",
|
||||
"DESCRIPTION": "Hide chosen categories behind a PIN. Locked categories, their channels and titles are removed from the whole app until the PIN is entered.",
|
||||
"ENABLE": "Enable parental lock",
|
||||
"ENABLE_DESCRIPTION": "Turning it on asks for a new PIN. Turning it off asks for the current PIN; your locks are kept for later.",
|
||||
"STATUS": "Current state",
|
||||
"STATUS_LOCKED": "Locked — locked categories are hidden.",
|
||||
"STATUS_UNLOCKED": "Unlocked — locked categories are visible until the app locks again.",
|
||||
"LOCK_NOW": "Lock now",
|
||||
"UNLOCK": "Unlock",
|
||||
"RELOCK": "Lock again after",
|
||||
"RELOCK_DESCRIPTION": "Minutes without interaction before the app locks itself again. Playback counts as interaction. Restarting the app always locks.",
|
||||
"RELOCK_NEVER": "Only when restarting the app",
|
||||
"RELOCK_MINUTES": "{{minutes}} minutes of inactivity",
|
||||
"CHANGE_PIN": "Change PIN",
|
||||
"CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.",
|
||||
"HOW_TO_LOCK": "Choosing what to lock",
|
||||
"HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.",
|
||||
"DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.",
|
||||
"ENABLED_TOAST": "Parental lock enabled",
|
||||
"DISABLED_TOAST": "Parental lock disabled",
|
||||
"PIN_CHANGED_TOAST": "PIN changed",
|
||||
"LOCKED_TOAST": "Locked"
|
||||
}
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色",
|
||||
@@ -1681,7 +1706,11 @@
|
||||
"SWITCH_PLAYER_LABEL": "切換播放器至 {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "將 {{name}} 設為使用中的影片播放器",
|
||||
"SWITCH_PLAYER_FEEDBACK": "播放器已設為 {{name}}",
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "顯示 Stalker 入口網站帳戶資訊"
|
||||
"ACCOUNT_INFO_DESCRIPTION_STALKER": "顯示 Stalker 入口網站帳戶資訊",
|
||||
"PARENTAL_LOCK_NOW_LABEL": "Lock now",
|
||||
"PARENTAL_LOCK_NOW_DESCRIPTION": "Hide the parental-locked categories again",
|
||||
"PARENTAL_UNLOCK_LABEL": "Enter parental PIN",
|
||||
"PARENTAL_UNLOCK_DESCRIPTION": "Show the parental-locked categories"
|
||||
},
|
||||
"XTREAM_REFRESH_TITLE": "正在重新整理播放清單",
|
||||
"XTREAM_REFRESH_COLLECTING_DATA": "正在保留你的媒體庫資料...",
|
||||
@@ -1872,5 +1901,39 @@
|
||||
"failed": "Deletion failed — select to retry"
|
||||
},
|
||||
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
|
||||
},
|
||||
"PARENTAL_LOCK": {
|
||||
"PIN_DIALOG": {
|
||||
"UNLOCK_TITLE": "Enter parental PIN",
|
||||
"UNLOCK_DESCRIPTION": "Locked categories stay hidden until the PIN is entered.",
|
||||
"SET_TITLE": "Set parental PIN",
|
||||
"SET_DESCRIPTION": "Choose a PIN that will be required to show locked categories.",
|
||||
"PIN_LABEL": "PIN",
|
||||
"CONFIRM_LABEL": "Repeat PIN",
|
||||
"PIN_HINT": "{{min}} to {{max}} digits",
|
||||
"WRONG_PIN": "Wrong PIN. Try again.",
|
||||
"MISMATCH": "The two PINs do not match.",
|
||||
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
|
||||
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
|
||||
"SAVE": "Save PIN",
|
||||
"UNLOCK": "Unlock",
|
||||
"CONFIRM_TITLE": "Confirm parental PIN",
|
||||
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
|
||||
},
|
||||
"LOCKED_COUNT": "Locked",
|
||||
"LOCK_CATEGORY": "Lock category",
|
||||
"UNLOCK_CATEGORY": "Unlock category",
|
||||
"LOCK_GROUP": "Lock group",
|
||||
"UNLOCK_GROUP": "Unlock group",
|
||||
"MANAGE_LOCKS": "Lock categories",
|
||||
"MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.",
|
||||
"LOCK_CENSORED": "Lock adult (18+)",
|
||||
"UNLOCK_ALL": "Unlock all",
|
||||
"CENSORED_HINT": "The portal flags this category as adult content",
|
||||
"SAVE_FAILED": "Saving the category locks failed",
|
||||
"LOCKED_CATEGORIES_ROW": "{{count}} locked",
|
||||
"UNLOCK_TO_SHOW": "Enter PIN to show",
|
||||
"HEADER_LOCK": "Lock now (parental lock)",
|
||||
"HEADER_UNLOCK": "Enter parental PIN"
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,18 @@ Otherwise the app may still be using an older
|
||||
`dist/apps/electron-backend/workers/database.worker.js` bundle even though the
|
||||
TypeScript source has already been updated.
|
||||
|
||||
### Parental Lock Toggles
|
||||
|
||||
While the parental lock is enabled (`Settings → Parental lock`), the same
|
||||
dialog renders a lock toggle per row and the whole dialog opens only after the
|
||||
PIN. Locks are a separate concept from `hidden`: they live in the renderer's
|
||||
lock store keyed by provider category id and are mirrored into
|
||||
`categories.locked` through `DB_SET_CATEGORY_LOCKS`, so a refresh keeps them
|
||||
the same way it keeps `hidden`. The dialog is the one entry point on every
|
||||
portal type (Stalker's "Manage categories" button opens its lock-only
|
||||
dialog); a right-click on a category row offers a single-row Lock/Unlock
|
||||
through the same lock store. Contract: [parental lock](parental-lock.md).
|
||||
|
||||
## Files Changed
|
||||
|
||||
```
|
||||
|
||||
@@ -255,7 +255,9 @@ entries: `@iptvnator/ui/components/window-controls`,
|
||||
add `SafePipe`; the eager EPG progress panel uses it too), like the existing
|
||||
`@iptvnator/ui/epg/progress-panel`, and loads anything used only on demand
|
||||
through a local file it imports dynamically (the Stalker connection editor,
|
||||
the release-notes dialog, the external-player info dialog). A local file,
|
||||
the release-notes dialog, the external-player info dialog, the parental-lock
|
||||
PIN dialog and the Stalker step of the parental-lock enforcement, which runs
|
||||
only while a Stalker route is open). A local file,
|
||||
not the library alias, is the dynamic-import target because
|
||||
`@nx/enforce-module-boundaries` forbids static imports of a library the same
|
||||
project also loads dynamically. `renderer.initialBytes` in
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
# Parental Lock
|
||||
|
||||
Issue #285. A PIN-protected lock for individual categories: Xtream
|
||||
categories, Stalker genres and M3U `group-title`s. While the lock is active,
|
||||
everything in a locked category is withheld from the app; entering the PIN
|
||||
shows it again until the app locks itself.
|
||||
|
||||
This document is the contract. Phase 1 (this document's scope) covers the
|
||||
portals' own surfaces; the aggregate surfaces listed under "Not yet covered"
|
||||
follow in a second PR.
|
||||
|
||||
## Product rules
|
||||
|
||||
- Off by default. Enabling asks for a new PIN (4–8 digits); disabling and
|
||||
changing the PIN always verify the current PIN against the stored hash,
|
||||
even while the session is unlocked (`verifyCurrentPin()`, never the
|
||||
`requestUnlock()` short-cut) — a parent leaving the app unlocked must not
|
||||
leave the lock removable. Disabling keeps the locks for a later re-enable.
|
||||
- The unit of locking is the category. Nothing is blurred or greyed: a
|
||||
withheld category and its rows are absent. The only trace is one
|
||||
"N locked · Enter PIN to show" row at the bottom of a portal's category
|
||||
rail, which opens the PIN prompt. In fail-closed mode (lock store
|
||||
unreadable) the row shows without a count, since which categories are
|
||||
locked is unknown while every one of them is withheld.
|
||||
- The unlock lives in memory only. The app locks again on every restart, on
|
||||
"Lock now" (header button, command palette, settings), and after
|
||||
`Settings.parentalLockRelockMinutes` minutes without user interaction
|
||||
(default 15; `0` = only on restart). The idle timer follows the UNLOCKED
|
||||
transition: armed the moment a session is unlocked — including the session
|
||||
that just enabled the feature — and disarmed on lock. Active playback of a built-in web
|
||||
player counts as interaction, so a film never locks half way — video
|
||||
through the keep-awake tracker, and audio (the radio player) read from
|
||||
the playing `<audio>` elements, since the keep-awake service ignores
|
||||
them.
|
||||
- This is a child lock, not a security boundary: the PIN hash and the lock
|
||||
store sit in user-readable app data. The UI says so. There is no PIN
|
||||
recovery; resetting the app data is the way out.
|
||||
|
||||
## Storage
|
||||
|
||||
| What | Where (Electron) | Where (PWA) |
|
||||
| ------------------ | --------------------------------------------------------------------------------- | ------------------------- |
|
||||
| PIN hash | `app_state` key `parental-lock:pin` | localStorage, same key |
|
||||
| Lock store | `app_state` key `parental-lock:locks` | localStorage, same key |
|
||||
| Feature switch | `Settings.parentalLockEnabled`, mirrored to electron-conf `PARENTAL_LOCK_ENABLED` | `Settings` |
|
||||
| Relock timeout | `Settings.parentalLockRelockMinutes` | same |
|
||||
| Xtream query index | `categories.locked` column | none (filtered in memory) |
|
||||
|
||||
The PIN is hashed with PBKDF2-SHA256 through WebCrypto
|
||||
(`parental-lock-pin.util.ts`, format `v1$<iterations>$<salt>$<hash>`), so
|
||||
Electron and PWA share one implementation. The hash is deliberately not part
|
||||
of `Settings`: settings are logged, backed up and mirrored to the main
|
||||
process. A hash that could not be READ is not an absent PIN:
|
||||
`ParentalLockStorageService.readPinHash()` reports the failure as `null`
|
||||
(an absent PIN is `{hash: null}`), the session then stays locked (`enabled`
|
||||
treats an unreadable PIN as set while the switch is unknown) and every
|
||||
PIN-protected step — unlock, change PIN, disable — re-reads it first, so
|
||||
storage answering later is enough to recover without a restart.
|
||||
|
||||
The lock store (`ParentalLockStore` in `parental-lock.util.ts`) is keyed by
|
||||
playlist id and holds, per playlist, Xtream `{categoryType, xtreamId}`
|
||||
pairs, Stalker `{categoryType, categoryId}` pairs and M3U group titles. It
|
||||
is the source of truth for all three portal types. Xtream additionally keeps
|
||||
`categories.locked` as a SQLite index derived from it: `DB_SET_CATEGORY_LOCKS`
|
||||
re-stamps one playlist/type from a provider-id list (idempotent, so the store
|
||||
can be replayed after a refresh or a backup restore), and `DB_SAVE_CATEGORIES`
|
||||
takes `lockedCategoryXtreamIds` so a refresh recreates the rows already
|
||||
stamped. Locks are keyed by provider ids, never by SQLite row ids, because a
|
||||
refresh deletes and re-inserts the rows.
|
||||
|
||||
## Enforcement — two layers, both default-closed
|
||||
|
||||
1. **SQLite worker (Electron).** `apps/electron-backend/src/app/database/parental-lock-state.ts`
|
||||
holds one process-wide flag. While active, every content-returning read
|
||||
appends "category is not locked": `getCategories`, `getContent`,
|
||||
`searchContent`, the three `globalSearch` candidate selectors,
|
||||
`getGlobalRecentlyAddedByType`, `DB_MATCH_TITLES` and the multi-source
|
||||
discovery queries. A surface added later is therefore withheld by
|
||||
default. The flag is seeded through `workerData.parentalLockActive` when
|
||||
the worker is (re)started and flipped by the `parental-lock` control
|
||||
message on the request port (ordered with the requests, so a read posted
|
||||
after it observes the new state). `getAllCategories` is exempt: it feeds
|
||||
the management dialog, which itself sits behind the PIN.
|
||||
2. **Renderer `ParentalLockService`** (`libs/services/src/lib/parental-lock/`).
|
||||
Signals `enabled`, `unlocked`, `active` (= enabled && !unlocked),
|
||||
`hasPin`, `version`; `requestUnlock()` (one shared prompt for concurrent
|
||||
callers), `lock()`, `setupPin()`, `changePin()`, `disable()`, the lock
|
||||
store API (`setXtreamLocks`, `setStalkerLocks`, `setM3uLocks`,
|
||||
`replacePlaylistLocks`, `lockedXtreamIds`, …) and the `is*Locked`
|
||||
predicates, which answer `true` only while `active`. The PIN dialog is
|
||||
reached through the `PARENTAL_LOCK_PROMPT` token, provided by the app
|
||||
(`AppParentalLockPromptService` → `ParentalLockPinDialogComponent` in
|
||||
`libs/ui/components`), so the data-access lib stays free of UI.
|
||||
Five wrong PINs pause the prompt for 30 seconds. The count and the
|
||||
pause live in the service (`createParentalLockPinThrottle`, handed to
|
||||
every unlock prompt), so dismissing the dialog and opening it again
|
||||
does not reset them.
|
||||
|
||||
The renderer reports `active` to the main process over
|
||||
`PARENTAL_LOCK_SET_STATE` (`apps/electron-backend/src/app/events/parental-lock.events.ts`),
|
||||
which forwards it to the worker. `SETTINGS_UPDATE` only persists the
|
||||
`parentalLockEnabled` mirror; it never derives the live state from a settings
|
||||
save (every save carries the flag unchanged and would re-lock the worker
|
||||
under a renderer that shows "unlocked"). The one exception is a switch-off,
|
||||
which releases the worker at once. `requestUnlock()` awaits `initialize()`
|
||||
before it can answer "not active", so a slow settings load cannot open a
|
||||
gate. Like the playback keep-awake vote, the
|
||||
renderer's word does not outlive its page: on reload, main-frame navigation or
|
||||
a dead render process the flag falls back to the mirrored
|
||||
`PARENTAL_LOCK_ENABLED` setting, i.e. locked while the feature is on. The
|
||||
renderer, in turn, reports nothing before its settings have loaded, so it can
|
||||
never send a spurious "unlocked" while the main process still holds the
|
||||
locked default. Unreadable settings fail the same way: `SettingsStore`
|
||||
serves defaults and records `storageFailure = 'load'`, and the switch is
|
||||
then unknown rather than off — a stored PIN stands in for it (`enabled`
|
||||
follows `hasPin`, so the session starts locked and one PIN entry unlocks
|
||||
it), and without a PIN the renderer does not report at all, leaving the
|
||||
main process on its mirrored default instead of announcing "unlocked" on
|
||||
the strength of default settings. The lock store itself fails closed the
|
||||
same way: `ParentalLockStorageService.readLocks()` reports a failed read as
|
||||
`null` (distinct from an absent store, `{}`; Electron reads through
|
||||
`DatabaseService.readAppState`, which keeps a rejected IPC apart from a
|
||||
missing key, and a stored payload that does not parse or does not have the
|
||||
shape `writeLocks` produces — `isWellFormedParentalLockStore`, all three
|
||||
lists present, down to the nested entries — is a failed read too — corruption never becomes an empty
|
||||
store), and while the lock is active with the store unreadable
|
||||
`ParentalLockService.withholdsEverything` is true — every `is*Locked`
|
||||
predicate answers true and the set-based filters (PWA Xtream, Stalker
|
||||
content and search, the M3U channel list) receive
|
||||
`ALL_CATEGORIES_WITHHELD`, and `ElectronXtreamDataSource` serves no
|
||||
categories, content, cached categories/content (the warm-route hydration
|
||||
path) or search hits either, since its SQLite index may still
|
||||
carry a stale stamp — under which a row WITHOUT a genre is withheld
|
||||
too (`isStalkerItemWithheld`, `isStalkerCategoryLocked`), since "no genre"
|
||||
must not be the one row a withheld catalog still shows — so the whole
|
||||
catalog is withheld until the PIN
|
||||
is entered or the store reads again (`requestUnlock` and every lock write
|
||||
retry the read first, and a write is refused while it still fails, since it
|
||||
would be built on an empty in-memory store and wipe the persisted locks).
|
||||
The same fail-closed mode applies while locked when Electron reads Xtream
|
||||
through the SQLite worker (`supportsXtreamSqliteDataSource`) but the bridge
|
||||
lacks the worker filter (`supportsParentalLockSqliteFilter`:
|
||||
`setParentalLockState` and `dbSetCategoryLocks`, e.g. a partial or older
|
||||
preload): the worker would never learn the lock state, so it cannot
|
||||
withhold locked rows itself. The same holds while locked after the
|
||||
lock-state sync itself was rejected (`ParentalLockWorkerSync`), until a
|
||||
later sync succeeds. The direct worker consumers
|
||||
(`CatalogTitleMatchService`, `VodSourceDiscoveryService`) ask the worker
|
||||
nothing while `withholdsEverything` is true, in either case. The VOD
|
||||
multi-source host keys its discovery session to the lock version: a lock
|
||||
change drops the discovered sources, retires discoveries and switches in
|
||||
flight, and rediscovers through the worker's new lock state. Title-match
|
||||
results cached by the Actor/Discover routes and dashboard services are not
|
||||
yet retired on relock (tracked in #1723).
|
||||
The window before the initial read settles is treated the same way
|
||||
(`ParentalLockLockStore.readable` is false until then): settings can report
|
||||
the feature as on before the locks are known — and the workspace route's
|
||||
`settingsReady` resolver awaits `ParentalLockService.initialize()` next to
|
||||
the settings load, so no route or catalog activates before the PIN and the
|
||||
lock store are known (in the PWA the settings read can be the slower one).
|
||||
Every lock write re-reads a failed store BEFORE building its edit, so a
|
||||
recovered store is edited, never overwritten by an edit built on the empty
|
||||
fail-closed one. The feature switch itself is
|
||||
persisted through one guarded path (`persistEnabled`), and every
|
||||
parental-lock settings write first retries a failed startup settings read
|
||||
(`ensureSettingsReadable`) and is refused while settings stay unreadable —
|
||||
`updateSettings` writes the whole settings object, which after a failed
|
||||
read is the defaults and would replace the user's persisted preferences.
|
||||
Within that path `updateSettings`
|
||||
patches memory before it writes, so a failed write is undone in memory and
|
||||
`setupPin`/`disable` report false (whether to persist is decided from the
|
||||
settings switch BEFORE the PIN is stored, since `enabled` follows `hasPin`
|
||||
while the switch is unknown); the Electron mirror write is awaited
|
||||
next, and a mirror that cannot be written undoes the settings write the
|
||||
same way — the toggle never shows a state the next launch will not have,
|
||||
on either side. The undo restores the value read AFTER the settings retry
|
||||
(not the hard-coded inverse, which a recovered read may already hold). The
|
||||
Settings switch itself only requests the change: it snaps back to the
|
||||
saved state at once and follows `enabled()` when the PIN action succeeds,
|
||||
so a cancelled or refused PIN leaves it showing the real state.
|
||||
|
||||
### In-memory catalogs
|
||||
|
||||
- **Xtream (Electron):** the store reads categories and streams through the
|
||||
worker, so they arrive filtered. On `version` changes
|
||||
`ParentalLockEnforcementService` (`apps/web/src/app/services/`) calls
|
||||
`XtreamStore.reloadCategories()` + `reloadCachedContent()` and, if the
|
||||
selected category vanished, clears the selection and navigates to the
|
||||
section root. The selected ITEM is judged separately, on its own
|
||||
`category_id` against the reloaded category list: a detail opened from
|
||||
"All", recently added or search has no selected category to vanish
|
||||
with, so its row disappearing from a list is not enough — the item is
|
||||
cleared and the route returns to the section root while the (still
|
||||
readable) selected category stays. Within one apply the synchronous
|
||||
surfaces (the M3U active channel, the Stalker selection) are stepped off
|
||||
BEFORE the Xtream reloads are awaited, so a playing M3U channel never
|
||||
waits behind a slow portal read — the Xtream store stays populated after
|
||||
leaving that portal, so its reload runs on every apply. The M3U check
|
||||
also runs whenever the active channel changes while locked: numeric
|
||||
zapping, next/previous and remote commands select from the full channel
|
||||
list without any lock change following, so a channel of a locked group
|
||||
is reset as soon as it becomes active (numeric zapping also skips it). Applies run one
|
||||
at a time and each is
|
||||
abandoned once a newer `version` exists (the queued apply reads the latest
|
||||
state): `ElectronXtreamDataSource` shares in-flight category/content
|
||||
reads per playlist and type, and its share key carries the lock version,
|
||||
so an unlock refresh still running when "Lock now" arrives can never hand
|
||||
the relock its unfiltered rows. The apply also re-runs the stored
|
||||
in-portal search (`XtreamStore.refreshSearchResults`, the last
|
||||
`searchContent` call as issued) — `searchResults` is a separate array the
|
||||
search page renders directly and would otherwise keep locked titles until
|
||||
the query changes. A RELOCK fails closed at once rather than after the
|
||||
database answers — ahead of the serialized apply queue, so an earlier
|
||||
apply still waiting on a slow or hung read cannot delay it: the selected detail is stepped off synchronously when
|
||||
the lock store already names its category (the pre-reload category list
|
||||
maps Electron's row id to the provider id) or when that list cannot place
|
||||
it at all (a manually hidden category opened through search), then
|
||||
`withholdCatalog()`
|
||||
empties every catalog list and `clearSearchResults()` the stored search
|
||||
(retiring a search still in flight, which was issued under the previous
|
||||
lock state) before the filtered reads refill them; both reloads take a publish guard
|
||||
answered before every state patch, so a read issued under an older lock
|
||||
version is dropped instead of published. The guard also turns false once
|
||||
another Xtream playlist is open (the store is a singleton), and the store
|
||||
itself refuses to publish a reload into a playlist it was not read for;
|
||||
the post-reload search refresh and selection checks are skipped then
|
||||
too. The post-reload checks of the
|
||||
selected category and item decide by the LOCK STORE through the
|
||||
unfiltered category rows (`IXtreamDataSource.getAllCategories`), not by
|
||||
absence from the reloaded list, which also omits categories the user
|
||||
merely hid; rows that cannot be read fail closed. A lock change that overtakes
|
||||
the INITIAL hydration (the content is not initialized yet, so the reload
|
||||
has nothing to re-read) sets a deferred-reload flag instead — already
|
||||
when `withholdCatalog()` empties the lists, before the category reload is
|
||||
awaited: the
|
||||
hydration then publishes empty lists in place of the rows it read under
|
||||
the previous lock state (categories included), and the filtered reload
|
||||
of categories and content runs as soon as the hydration settles, on
|
||||
every path that marks the content initialized, under the publish guard
|
||||
of the request that deferred it.
|
||||
Both reloads fail closed: a category reload that
|
||||
rejects empties the three category lists, and a per-type content reload
|
||||
that rejects empties that type and sets it back to `idle` so the next
|
||||
visit loads it again (filtered) — rows read under the previous lock state
|
||||
are never kept. Live playback is stopped by the layout itself:
|
||||
`LiveStreamLayoutComponent` keeps the playing channel's provider category
|
||||
id (mapped from the SQLite row id on Electron — through the visible
|
||||
category list, else through the unfiltered rows, since search can play a
|
||||
channel of a HIDDEN category; until that lookup lands the id is unknown
|
||||
and a relock stops the channel, and a lookup that lands after a later
|
||||
playback — same provider id, other playlist — is discarded) and drops `activePlayback` on a `version`
|
||||
change that locks it, because the player is gated on that
|
||||
signal, not on the store selection — which an ordinary category switch
|
||||
also clears while the channel keeps playing.
|
||||
- **Stalker:** the same service checks the selected genre through
|
||||
`isStalkerCategoryLocked` and, independently, the selected item's own
|
||||
genre — `tv_genre_id` for live and radio rows, `category_id` for VOD and
|
||||
series, the rule the store's withheld filter applies — so an item opened
|
||||
from `*` (All) or search is withheld by its genre even though `*` itself
|
||||
can never be locked. A withheld item is cleared and the section root is
|
||||
navigated to; the category selection is reset only when the genre itself
|
||||
is locked. A stream still RESOLVING at relock time is retired too: the
|
||||
embedded player defers selecting the channel until resolution, so the
|
||||
cleared selection cannot retire it — the playback request carries the
|
||||
lock version it was issued under (`isStalkerPlaybackRequestLockCurrent`)
|
||||
and is dropped when the session relocked meanwhile (an unlock lets it
|
||||
complete). `StalkerLiveStreamLayoutComponent` drops its `activePlayback`
|
||||
whenever the store selection is cleared: its template mounts the player
|
||||
only with a selection, and the held stream must not resurface with the
|
||||
next one.
|
||||
- **M3U groups rail:** `ChannelListContainerComponent` derives
|
||||
`withheldGroupCount` (groups of the playlist the active lock withholds)
|
||||
and the groups rail renders the same "N locked · Enter PIN to show" row
|
||||
as the portal category rail, so locked groups do not simply vanish —
|
||||
also when EVERY group is locked: the container keeps the groups view
|
||||
(`showChannelViews`) and the groups view keeps its rail instead of the
|
||||
generic empty state.
|
||||
- **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories,
|
||||
streams and search hits at read time; the same reloads apply. Provider
|
||||
category ids are compared in canonical numeric form (`"009"` matches the
|
||||
stored lock `9`), as the lock editor stores them with `Number`.
|
||||
- **Warm-cache detection (Electron):** the filtered category/content reads
|
||||
can be empty while the offline cache is complete (every category locked),
|
||||
so `ElectronXtreamDataSource` confirms an empty read with the unfiltered
|
||||
`hasXtreamCategories` / `hasXtreamContent` before refetching from the
|
||||
provider.
|
||||
- **Routes:** `parentalLockXtreamCategoryGuard(section)` on every Xtream
|
||||
`:categoryId` route (live, vod, series and their detail children) and
|
||||
`parentalLockStalkerCategoryGuard(section)` on the Stalker vod/series
|
||||
`:categoryId` routes prompt for the PIN when a locked category is reached
|
||||
by URL — bookmark, typed address, stale link — and redirect to the section
|
||||
root on refusal. Detail routes also resolve the ITEM's own category through
|
||||
`getContentByXtreamId`, so a locked movie paired with an unlocked category
|
||||
id in the URL is still refused; on a cold PWA navigation the guard
|
||||
hydrates the session cache first (`getPlaylist` + `getContent`), and an
|
||||
item the catalog cannot place fails closed (PIN prompt) rather than
|
||||
passing as unlocked. Electron routes carry SQLite row ids, so
|
||||
the Xtream guard maps them through the unfiltered category read; Stalker
|
||||
routes already carry the genre id.
|
||||
- **Stalker withheld-row bookkeeping:** the paging logic that advances past
|
||||
a page made only of withheld rows keys those rows by
|
||||
`stalkerWithheldRowKey` (`id`, else `stream_id`/`movie_id`/`series_id`,
|
||||
else the row's `cmd`/`name`), never by a shared `''`, so a page of new
|
||||
locked rows is not mistaken for a stalled portal.
|
||||
- **Stalker search relock:** a lock change drops the withheld rows already
|
||||
on screen at once (`applyRelockToResults`, page 1 included) and closes an
|
||||
open detail of a now-withheld genre (in fail-closed mode also one
|
||||
without a genre), before the replacement page is awaited — otherwise
|
||||
they stay clickable while that request is pending.
|
||||
- **Stalker search staleness:** portal requests are not aborted, so a
|
||||
search page issued before a relock can finish after it, filtered with the
|
||||
pre-relock withheld set; `isStalkerSearchRequestCurrent` keys the
|
||||
response on the parental lock version as well as term/type/page/portal,
|
||||
so such a page is dropped instead of repopulating the grid.
|
||||
- **Stalker search route** (`/workspace/stalker/:id/search`, no category
|
||||
in the URL): `StalkerSearchComponent` filters each portal page through the
|
||||
same withheld-genre predicate, carries `parentalLockVersion` in its
|
||||
resource params, judges paging progress on the raw page (a page made only
|
||||
of locked rows is not the end of the results), advances past a run of
|
||||
fully withheld pages by itself (the infinite scroll stops auto-filling
|
||||
after a few no-growth loads), closes an open detail whose genre is newly
|
||||
withheld and, on a lock flip past page 1, drops the withheld rows and
|
||||
restarts from page 1.
|
||||
- **Stalker:** genres are stored unfiltered; `getCategoryResource` filters
|
||||
them, `getAllCategoriesForSelectedType` is the raw list for the lock
|
||||
dialog. `itvFullChannelList` and the content loader drop rows whose
|
||||
`tv_genre_id` / `category_id` is withheld, the content resource's params
|
||||
carry `parentalLockVersion` so lock/unlock re-fires it, and a stale
|
||||
response is discarded when the version moved. Paging is judged on the RAW
|
||||
page: withheld ids the list has not seen before count as progress (so a
|
||||
portal page made only of locked rows does not end the list), a page that
|
||||
is entirely withheld requests the next page by itself, and the VOD/series
|
||||
`totalCount` is reduced by the withheld ids seen so the grid stops asking
|
||||
once every visible row is in. A lock flip while the list sits past page 1
|
||||
drops the withheld rows on screen at once and restarts from page 1, so rows
|
||||
accumulated under the old lock state are never appended to. A selected
|
||||
withheld genre is cleared and the
|
||||
section root is navigated to.
|
||||
- **M3U:** `ChannelListContainerComponent` derives one `visibleChannelList`
|
||||
(all views, favorites, recents, the fullscreen panel and numeric zapping
|
||||
read from it); locked groups are absent from the groups rail. A playing
|
||||
channel of a locked group is reset on lock.
|
||||
|
||||
## UI
|
||||
|
||||
- `Settings → Parental lock` (`/workspace/settings/parental`): enable
|
||||
toggle, state + Lock now / Unlock, relock select, Change PIN, the
|
||||
disclaimer. Everything applies immediately (it gates on the PIN), outside
|
||||
the shared dirty form; `createSettingsFromFormValue` carries both values
|
||||
from the current settings so Save cannot undo them.
|
||||
- One entry point per rail, the same on every portal type: the "Manage
|
||||
categories" button (`tune` icon; "Manage groups" on an M3U playlist).
|
||||
Xtream and M3U render lock toggles inside their existing hide/show
|
||||
dialogs while the feature is on; Stalker has no hide/show dialog, so its
|
||||
button — shown only while the feature is on — opens the lock-only
|
||||
`StalkerCategoryLockDialogComponent`, which also offers "Lock adult
|
||||
(18+)" for genres the portal flags `censored`. All three list the locked
|
||||
names and can rewrite the locks, so each opens only after
|
||||
`requestUnlock()` succeeds — and closes itself, discarding the draft, the
|
||||
moment `active` becomes true again (idle relock, Lock now), since the
|
||||
PIN gate covers only the opening. The M3U group dialog does this only
|
||||
while it carries lock toggles; the plain hide/show editor is not behind
|
||||
the PIN. The Xtream dialog loads its candidates through the
|
||||
capability-selected data source (`IXtreamDataSource.getAllCategories`,
|
||||
which the PWA source answers from its session cache or the API), so PWA
|
||||
users can set locks too; the hide/show checkboxes remain Electron-only.
|
||||
A deliberate non-choice: no dedicated lock button in the rail header
|
||||
(the header already carries search, sort and manage, and the lock is a
|
||||
category-management concern) and no keyword-based "adult" recognition
|
||||
for Xtream/M3U (provider naming is arbitrary; the only automation is the
|
||||
portal's own `censored` flag on Stalker).
|
||||
- Right-click accelerator: a `contextmenu` on a category row (Xtream and
|
||||
Stalker rail, `WorkspaceContextCategoryViewComponent`) or an M3U group
|
||||
row opens the shared `CategoryLockMenuComponent` (`libs/ui/components`)
|
||||
with one "Lock / Unlock category (group)" item, only while the feature
|
||||
is on. The portal rail persists through
|
||||
`WorkspaceCategoryLockActionService` (PIN gate, then one-id edit of the
|
||||
lock store, failure snackbar); the M3U rail emits the toggled group to
|
||||
`ChannelListContainerComponent`, which captures the playlist, asks for
|
||||
the PIN, and persists the edit only if that playlist is still open (two
|
||||
playlists can share a group name). The M3U management dialog is bound
|
||||
the same way: `GroupsViewComponent` captures its `playlistId` input
|
||||
before the PIN and drops the dialog's result once another playlist is
|
||||
open. Every bulk lock editor (Xtream and M3U management dialogs, the
|
||||
Stalker lock dialog) builds its draft only from a lock store that was
|
||||
read (`ensureLocksReadable`): a draft taken from the empty fail-closed
|
||||
snapshot would erase the real locks on Save if storage recovered in
|
||||
between. The M3U and Xtream dialogs then open without lock toggles; the
|
||||
Stalker dialog does not open. Every one of them closes on a relock, the
|
||||
M3U one also when it opened without toggles, since it still lists every
|
||||
group name. Bulk actions stay in the dialog, and while the session is locked
|
||||
a locked category is not in the rail, so unlocking always goes through
|
||||
the "N locked · Enter PIN to show" row or the dialog. The M3U dialog
|
||||
hands its lock list back to `ChannelListContainerComponent`, which
|
||||
awaits the write and reports a failed save in a snackbar (the dialog has
|
||||
closed by then). On Electron the `categories.locked` re-stamp
|
||||
(`setCategoryLocks`) clears and re-locks one playlist/type inside ONE
|
||||
transaction, so a failed restamp keeps the previous index instead of
|
||||
leaving every category unlocked. The relock timeout persists through the
|
||||
same undo-on-failure pattern as the switch (`setRelockMinutes` reverts
|
||||
the in-memory value and the facade shows the settings save-failure
|
||||
snackbar). A locked session cannot change it: the selector is disabled
|
||||
until the PIN is entered, and `setRelockMinutes` refuses while `active`,
|
||||
so a child cannot switch the idle relock off for the next unlock.
|
||||
- Header lock/unlock button and the `parental-lock-now` /
|
||||
`parental-unlock` palette commands.
|
||||
|
||||
## Startup footprint
|
||||
|
||||
The lock service, lock store and enforcement are on the renderer's initial
|
||||
path by necessity (the workspace resolver and the catalog data sources gate
|
||||
on them). Everything else stays lazy: the PIN dialog loads through
|
||||
`parental-lock-pin-dialog.lazy.ts` on the first prompt (a failed chunk load
|
||||
reads as a cancelled prompt), and the enforcement's Stalker step loads
|
||||
through `parental-lock-stalker-enforcement.ts` only while a Stalker route is
|
||||
open — a static import of the Stalker store would put the whole Stalker
|
||||
data layer back into `main.js`. The chunk is preloaded when a Stalker route
|
||||
opens, so a relock there runs the step synchronously; if it is not loaded
|
||||
yet (still fetching, or it cannot load — a stale PWA page after a
|
||||
deployment) the step fails closed at once by navigating to
|
||||
`/workspace/sources`: leaving the Stalker route clears its selection and
|
||||
stops its playback, and the Xtream step still runs. The feature costs about 30 KB of
|
||||
`renderer.initialBytes`.
|
||||
|
||||
## Lock store lifetime
|
||||
|
||||
On Electron the SQLite `categories.locked` index is derived from the lock
|
||||
store and must never lag behind it, because the worker filters every read
|
||||
by the index alone:
|
||||
|
||||
- The store commits BEFORE the re-stamp, so a failed re-stamp rolls the
|
||||
store back to the previous locks AND re-stamps every touched type from
|
||||
them (a backup restore stamps three types, and the ones before the
|
||||
failing type already carry the new locks). The store revision consumers
|
||||
reload on is published only once every touched type is stamped — also
|
||||
after a rollback — so a reload cannot read a later type through its old
|
||||
stamps. If the rollback write or its re-stamp fails too, the playlist is
|
||||
marked stale and re-stamped on the next store access. A failed rollback
|
||||
WRITE still puts memory back to the previous locks and leaves a pending
|
||||
rewrite: the next store access persists them before it re-stamps, so the
|
||||
failed edit cannot take effect through that re-stamp. The pending rewrite
|
||||
lives in memory: if the app exits before it lands, the next launch loads
|
||||
the persisted copy, i.e. the edit the parent submitted with the PIN. That
|
||||
is accepted — it is not a bypass, and a durable rollback journal would
|
||||
hit the same storage failure. For the whole
|
||||
re-stamp window — store changed, stamps not yet landed — the playlist
|
||||
counts as stale, so `readable` is false and a relock inside the window
|
||||
reloads fail-closed instead of through the old stamps. Such in-flight
|
||||
entries are not retried by a reconcile running beside the write (a PIN
|
||||
prompt or backup calling `ensureReadable()`): it would stamp from a store
|
||||
the write has not committed yet. Only failed stamps are retryable.
|
||||
- A write that removes a playlist's LAST lock clears the index first and
|
||||
drops the key afterwards: the launch-time reconcile finds playlists only
|
||||
through their key, so an interruption must leave store-with-lock and
|
||||
index-without, which the next reconcile repairs toward locked. If the
|
||||
clear or the store write fails, the index is re-stamped from the previous
|
||||
locks at once — title matching and multi-source discovery query the
|
||||
worker directly and trust the index, so a stale flag alone would not
|
||||
protect them. The playlist stays stale until the store write has landed
|
||||
too: while it is pending the index is already cleared but the store
|
||||
still holds the lock.
|
||||
- An edit that takes a lock away commits only while the session is
|
||||
unlocked, checked inside the write queue at commit time
|
||||
(`ParentalLockLockStore.setRemovalGate`, set by `ParentalLockService`).
|
||||
An editor opened while unlocked may still be saving, or queued behind
|
||||
another write, when the app relocks. The gate is asked right before the
|
||||
edit's first write is issued; a relock that lands after that is ordered
|
||||
after the write, which completes: the parent authorized that removal,
|
||||
and a post-write rollback could itself fail and leave the persisted
|
||||
store diverged from memory across a restart. (During the Xtream stamps
|
||||
the playlist is stale anyway, so the relock reloads fail-closed until
|
||||
they land.) Adding locks is always allowed.
|
||||
Playlist deletion and "Remove all playlists" are not edits and are not
|
||||
gated.
|
||||
- Every launch re-derives the index from the store for each playlist that
|
||||
has locks, and a store recovered by a later successful read marks its
|
||||
playlists stale the same way. The reconcile is awaited inside the store's
|
||||
`load()`, so `readable` (and with it every catalog read the renderer
|
||||
gates) stays false until the index agrees with the store; a re-stamp that
|
||||
keeps failing keeps the session fail-closed.
|
||||
|
||||
Every lock-store mutation runs through one write queue in
|
||||
`ParentalLockLockStore`: each rewrites the whole persisted store from the
|
||||
in-memory copy, so overlapping edits (two right-click toggles, a dialog
|
||||
save during a restore) would otherwise snapshot the same store and the
|
||||
later write would drop the earlier edit. Single-row changes (the
|
||||
right-click Lock/Unlock) are passed as an EDIT of the current list
|
||||
(`LockListEdit`), evaluated inside that queue, so two quick toggles each
|
||||
see the other's result instead of one shared snapshot; the dialogs pass a
|
||||
full list, which is the replacement they mean. A deleted playlist's locks leave
|
||||
the store through the `PLAYLIST_DELETE_CLEANUP` hook
|
||||
(`provideParentalLockPlaylistCleanup`, run by
|
||||
`PlaylistsService.deletePlaylist` for every single-playlist deletion; no
|
||||
re-stamp, the category rows go with the playlist), and "Remove all
|
||||
playlists" empties it through `ParentalLockService.clearAllLocks` once the
|
||||
deletion has succeeded — the in-memory store empties at once, and a failed
|
||||
persisted clear is retried on the next store access (the store is not
|
||||
`readable` until it lands). A backup restore that CREATES a playlist (not a merge) starts
|
||||
that playlist from empty locks, so a reused id can never inherit entries a
|
||||
failed cleanup left behind; the restore retries a failed lock-store read
|
||||
first (`ensureLocksReadable`) and aborts while it still fails, so that check
|
||||
never runs against the empty fail-closed snapshot.
|
||||
|
||||
## Backup
|
||||
|
||||
A backup carrying lock lists replaces the matching playlists' locks,
|
||||
possibly with an emptier set, so the import asks for the PIN first (after
|
||||
the file was chosen) and aborts when it is refused. That answer can go
|
||||
stale during a long import (idle relock, "Lock now"), so an entry whose
|
||||
restore would take a lock away asks again right before it replaces locks
|
||||
if the app has relocked; a refusal fails that entry and keeps its previous
|
||||
locks. A restore that only adds locks is not asked again. Locks are
|
||||
restored LAST
|
||||
for each entry, after the Xtream data restore, so a
|
||||
failed merge leaves the playlist's previous locks in place. M3U group titles
|
||||
travel verbatim (`normalizeParentalLockGroupTitles`, exact
|
||||
dedup): locks match `channel.group.title` exactly, so the trimming
|
||||
`uniqueStrings` used for favorites would weaken a lock on a title with
|
||||
surrounding whitespace. A backup's lock lists are validated entry by entry
|
||||
on import (`isWellFormedParentalLock*` in the shared contract): restore
|
||||
treats them as authoritative and the normalizer drops what it does not
|
||||
understand, so a damaged list is rejected rather than erasing the
|
||||
playlist's persisted protection.
|
||||
|
||||
`lockedGroupTitles` (M3U), `lockedCategories` (Xtream `{categoryType,
|
||||
xtreamId}`, Stalker `{categoryType, categoryId}`) travel in each entry's
|
||||
`userState`, written only when the playlist has locks. Absent means "no
|
||||
opinion" and restore leaves the target's locks alone; present replaces them
|
||||
(`replacePlaylistLocks`, which also re-stamps the Xtream index). The PIN is
|
||||
never exported.
|
||||
|
||||
## Not yet covered (phase 2)
|
||||
|
||||
Favorites, recently viewed, playback positions / Continue Watching, the
|
||||
dashboard rails built from Stalker and M3U documents, global favorites /
|
||||
recent pages, the EPG guide, downloads and recordings lists, the remote
|
||||
control's channel-number resolvers, the command palette's channel search and
|
||||
the Electron-only global search page (`/workspace/search`, whose stored
|
||||
results are not re-run on a relock) still show rows from locked categories. The SQLite-side hooks exist
|
||||
(`unlockedCategoryCondition()` / `unlockedCategorySql()`), the renderer
|
||||
predicate is `ParentalLockService.is*Locked`.
|
||||
|
||||
## Files
|
||||
|
||||
- Contracts: `libs/shared/interfaces/src/lib/parental-lock.util.ts`,
|
||||
`parental-lock-pin.util.ts`, `Settings.parentalLock*`,
|
||||
`PARENTAL_LOCK_SET_STATE`.
|
||||
- Renderer: `libs/services/src/lib/parental-lock/` (`ParentalLockService`
|
||||
owns the session state and PIN flows; `ParentalLockLockStore` owns the
|
||||
persisted lock set, its unreadable state and the SQLite re-stamp),
|
||||
`apps/web/src/app/services/parental-lock-prompt.service.ts`,
|
||||
`parental-lock-enforcement.service.ts`,
|
||||
`apps/web/src/app/settings/settings-parental-section.component.*` and `settings-parental-lock.facade.ts` (rows anchored and indexed for settings search),
|
||||
`libs/ui/components/src/lib/parental-lock-pin-dialog/`,
|
||||
`libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/`.
|
||||
- Electron: `apps/electron-backend/src/app/database/parental-lock-state.ts`,
|
||||
`services/parental-lock-state.ts`, `events/parental-lock.events.ts`,
|
||||
`database/operations/category.operations.ts` (`setCategoryLocks`).
|
||||
@@ -52,7 +52,10 @@ Current workspace routes:
|
||||
8. `/workspace/search`
|
||||
9. `/workspace/downloads`
|
||||
10. `/workspace/settings/:section` (`/workspace/settings` redirects to
|
||||
`general`; the settings context panel links each section page)
|
||||
`general`; the settings context panel links each section page; sections:
|
||||
`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`,
|
||||
`parental` — see [parental lock](parental-lock.md) — `backup`, `reset`,
|
||||
`about`)
|
||||
11. `/workspace/xtreams/:id/...`
|
||||
12. `/workspace/stalker/:id/...`
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ are not prerequisites for reading repository contracts.
|
||||
| Live panels, keyboard focus, grid/layout conventions; shared UI and portal views | [UI guidelines](../architecture/iptvnator-ui-guidelines.md), [detail navigation](../architecture/portal-detail-navigation.md) | [UI design](../../.codex/skills/iptvnator-ui-design/SKILL.md), [theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) |
|
||||
| Workspace routes, title bar, switcher, collections and dashboard; `libs/workspace` | [Workspace shell](../architecture/workspace-shell.md), [dashboard](../architecture/workspace-dashboard.md), [collection/detail navigation](../architecture/portal-detail-navigation.md) | UI/theme skills for visible changes |
|
||||
| Remote control, playback queue, channel return and shortcuts; `libs/ui/remote-control`, `apps/remote-control-web` | [Remote control](../architecture/remote-control.md) | Provider skill when queue ownership changes |
|
||||
| Parental lock: PIN, per-category locks, worker-side filtering; `libs/services/src/lib/parental-lock`, category/group dialogs, `apps/electron-backend/src/app/database/parental-lock-state.ts` | [Parental lock](../architecture/parental-lock.md), affected provider contract | Read the affected provider skill |
|
||||
| Downloads, offline details, catch-up and file availability; `libs/portal/downloads` | [Download manager](../architecture/download-manager.md), provider contract for URL resolution | Read the affected provider skill |
|
||||
| VOD source discovery, factual metadata and failover; `libs/portal/shared/data-access` | [VOD multi-source](../architecture/vod-multi-source.md) | [Xtream](../../.codex/skills/xtream-electron/SKILL.md) |
|
||||
| TMDB enrichment, artwork, actors and recommendations; `libs/services/src/lib/tmdb` | [TMDB contracts](../architecture/tmdb-metadata-enrichment.md), [dashboard](../architecture/workspace-dashboard.md) | UI skill for rendering changes |
|
||||
|
||||
@@ -114,6 +114,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
DataService,
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
RecordingsService,
|
||||
RuntimeCapabilitiesService,
|
||||
@@ -241,6 +242,7 @@ export class VideoPlayerComponent
|
||||
private readonly router = inject(Router);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly storage = inject(StorageMap);
|
||||
private readonly store = inject(Store);
|
||||
private readonly epgService = inject(EpgService);
|
||||
@@ -1437,8 +1439,19 @@ export class VideoPlayerComponent
|
||||
)
|
||||
)
|
||||
.subscribe((channel) => {
|
||||
// The number indexes the full list; a channel of a locked
|
||||
// group is not zapped to (the enforcement service would
|
||||
// reset it anyway, after playback had started).
|
||||
const playlistId = this.activePlaylistMeta()?._id;
|
||||
if (
|
||||
channel &&
|
||||
!(
|
||||
playlistId &&
|
||||
this.parentalLock.isM3uGroupLocked(
|
||||
playlistId,
|
||||
channel.group?.title ?? ''
|
||||
)
|
||||
) &&
|
||||
(!this.isLivePlayerFullscreen() ||
|
||||
this.keepsInlinePlayer(channel))
|
||||
) {
|
||||
|
||||
+41
-2
@@ -1,5 +1,23 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { VodSourceDiscoveryService } from './vod-source-discovery.service';
|
||||
|
||||
const withholdsEverything = signal(false);
|
||||
|
||||
function createService(): VodSourceDiscoveryService {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: ParentalLockService,
|
||||
useValue: { withholdsEverything },
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.inject(VodSourceDiscoveryService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovery talks to a foreign playlist, and Xtream carries the account in the
|
||||
* URL — so anything it fails on has to go through the redacting logger before
|
||||
@@ -17,7 +35,7 @@ describe('VodSourceDiscoveryService — failure logging', () => {
|
||||
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {
|
||||
/* captured */
|
||||
});
|
||||
service = new VodSourceDiscoveryService();
|
||||
service = createService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -85,7 +103,7 @@ describe('VodSourceDiscoveryService — candidate mapping', () => {
|
||||
row(4, undefined),
|
||||
]),
|
||||
};
|
||||
const service = new VodSourceDiscoveryService();
|
||||
const service = createService();
|
||||
|
||||
const result = await service.discover({
|
||||
title: 'Dune',
|
||||
@@ -111,3 +129,24 @@ describe('VodSourceDiscoveryService — candidate mapping', () => {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
describe('VodSourceDiscoveryService — parental lock', () => {
|
||||
afterEach(() => {
|
||||
withholdsEverything.set(false);
|
||||
delete (window as { electron?: unknown }).electron;
|
||||
});
|
||||
|
||||
it('asks the worker nothing while the lock withholds everything', async () => {
|
||||
const dbFindTitleSources = jest.fn().mockResolvedValue([]);
|
||||
(window as { electron?: unknown }).electron = { dbFindTitleSources };
|
||||
withholdsEverything.set(true);
|
||||
|
||||
const result = await createService().discover({
|
||||
title: 'Dune',
|
||||
currentPlaylistId: 'playlist-0',
|
||||
});
|
||||
|
||||
expect(result.sources).toEqual([]);
|
||||
expect(dbFindTitleSources).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+11
-2
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
unambiguousCategoryLanguage,
|
||||
type VodSourceCandidate,
|
||||
@@ -43,6 +44,7 @@ export interface VodSourceDiscoveryResult {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VodSourceDiscoveryService {
|
||||
private readonly logger = createLogger('VodSourceDiscovery');
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return (
|
||||
@@ -59,7 +61,14 @@ export class VodSourceDiscoveryService {
|
||||
matchKind: 'title-year',
|
||||
};
|
||||
|
||||
if (!this.isAvailable || !request.title?.trim()) {
|
||||
// The worker withholds locked rows through its own lock state and
|
||||
// index; while the lock withholds everything (store unreadable, or
|
||||
// a bridge without the worker filter) neither can be trusted.
|
||||
if (
|
||||
!this.isAvailable ||
|
||||
!request.title?.trim() ||
|
||||
this.parentalLock.withholdsEverything()
|
||||
) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
|
||||
+155
-1
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { DataService, ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
CONNECTIVITY_GUARD_RESET,
|
||||
PlaylistMeta,
|
||||
@@ -138,16 +138,27 @@ describe('withStalkerContent failure states', () => {
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
};
|
||||
let parentalLock: {
|
||||
active: jest.Mock<boolean, []>;
|
||||
version: ReturnType<typeof signal<number>>;
|
||||
lockedStalkerIds: jest.Mock<string[], [string, string]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
parentalLock = {
|
||||
active: jest.fn(() => false),
|
||||
version: signal(0),
|
||||
lockedStalkerIds: jest.fn(() => []),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestContentStore,
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{
|
||||
provide: StalkerItvCacheService,
|
||||
useValue: createItvCacheMock(),
|
||||
@@ -590,6 +601,149 @@ describe('withStalkerContent failure states', () => {
|
||||
expect(store.totalCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('pages past a VOD page made only of parental-locked rows', async () => {
|
||||
parentalLock.active.mockReturnValue(true);
|
||||
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event: unknown, payload: { params?: { p?: number } }) => {
|
||||
const page = Number(payload.params?.p ?? 1);
|
||||
// Page 1: one visible film. Page 2: locked rows only. Page 3:
|
||||
// the visible film paging must still reach.
|
||||
const data =
|
||||
page === 1
|
||||
? [{ id: 'movie-1', name: 'One', category_id: '5' }]
|
||||
: page === 2
|
||||
? [
|
||||
{ id: 'adult-1', name: 'A', category_id: '9' },
|
||||
{ id: 'adult-2', name: 'B', category_id: '9' },
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'movie-3',
|
||||
name: 'Three',
|
||||
category_id: '5',
|
||||
},
|
||||
];
|
||||
return Promise.resolve({ js: { data, total_items: 4 } });
|
||||
}
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
{ category_id: '9', category_name: 'Adult' },
|
||||
]);
|
||||
store.setSelectedCategory('*');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 1);
|
||||
expect(store.hasMoreContent()).toBe(true);
|
||||
|
||||
// The append lands on the fully withheld page; the loader must ask
|
||||
// for the next one by itself instead of ending the list.
|
||||
store.setPage(1);
|
||||
await waitForCondition(
|
||||
() => store.getPaginatedContent().length === 2,
|
||||
60
|
||||
);
|
||||
|
||||
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
|
||||
'movie-1',
|
||||
'movie-3',
|
||||
]);
|
||||
// Both withheld ids are subtracted from the portal's total.
|
||||
expect(store.totalCount()).toBe(2);
|
||||
expect(store.hasMoreContent()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops accumulated rows of a newly locked genre and restarts from page 1', async () => {
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
(_event: unknown, payload: { params?: { p?: number } }) => {
|
||||
const page = Number(payload.params?.p ?? 1);
|
||||
const data =
|
||||
page === 1
|
||||
? [
|
||||
{ id: 'movie-1', name: 'One', category_id: '5' },
|
||||
{ id: 'adult-1', name: 'A', category_id: '9' },
|
||||
]
|
||||
: [{ id: 'movie-2', name: 'Two', category_id: '5' }];
|
||||
return Promise.resolve({ js: { data, total_items: 3 } });
|
||||
}
|
||||
);
|
||||
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
{ category_id: '9', category_name: 'Adult' },
|
||||
]);
|
||||
store.setSelectedCategory('*');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 2);
|
||||
store.setPage(1);
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 3);
|
||||
|
||||
// Lock now: the genre-9 row loaded on page 1 must leave the screen
|
||||
// and the list must be rebuilt from page 1 under the new lock state.
|
||||
parentalLock.active.mockReturnValue(true);
|
||||
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
|
||||
parentalLock.version.set(1);
|
||||
|
||||
await waitForCondition(
|
||||
() =>
|
||||
store.page() === 0 &&
|
||||
!store.isPaginatedContentLoading() &&
|
||||
store
|
||||
.getPaginatedContent()
|
||||
.every((item) => item.category_id !== '9'),
|
||||
60
|
||||
);
|
||||
|
||||
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
|
||||
'movie-1',
|
||||
]);
|
||||
expect(store.totalCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('takes page-one rows of a newly locked genre off screen before the reload answers', async () => {
|
||||
let hangReload = false;
|
||||
dataService.sendIpcEvent.mockImplementation(() =>
|
||||
hangReload
|
||||
? new Promise(() => undefined)
|
||||
: Promise.resolve({
|
||||
js: {
|
||||
data: [
|
||||
{ id: 'movie-1', name: 'One', category_id: '5' },
|
||||
{ id: 'adult-1', name: 'A', category_id: '9' },
|
||||
],
|
||||
total_items: 2,
|
||||
},
|
||||
})
|
||||
);
|
||||
store.setSelectedContentType('vod');
|
||||
store.setCategories('vod', [
|
||||
{ category_id: '5', category_name: 'Action' },
|
||||
{ category_id: '9', category_name: 'Adult' },
|
||||
]);
|
||||
store.setSelectedCategory('*');
|
||||
store.setCurrentPlaylist(PLAYLIST);
|
||||
void store.isPaginatedContentLoading();
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 2);
|
||||
|
||||
// Lock now while the replacement request hangs.
|
||||
hangReload = true;
|
||||
parentalLock.active.mockReturnValue(true);
|
||||
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
|
||||
parentalLock.version.set(1);
|
||||
|
||||
// Page 1 blanks the grid synchronously before its request, so the
|
||||
// locked row is gone even though the reload never answers.
|
||||
await waitForCondition(() => store.getPaginatedContent().length === 0);
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps accumulated pages when an append fails and retries the same page', async () => {
|
||||
let failPageTwo = true;
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
|
||||
+220
-20
@@ -9,7 +9,13 @@ import {
|
||||
} from '@ngrx/signals';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { DataService, resetHostConnectivityGuard } from '@iptvnator/services';
|
||||
import {
|
||||
DataService,
|
||||
ParentalLockService,
|
||||
resetHostConnectivityGuard,
|
||||
} from '@iptvnator/services';
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
StalkerCategoryItem,
|
||||
StalkerContentItem,
|
||||
@@ -31,8 +37,29 @@ import {
|
||||
filterItvChannelsByGenre,
|
||||
toStalkerContentItem,
|
||||
toStalkerItvChannel,
|
||||
stalkerWithheldRowKey,
|
||||
withoutWithheldStalkerItems,
|
||||
} from '../utils';
|
||||
|
||||
/**
|
||||
* Genre ids of the given section that the parental lock currently withholds
|
||||
* for this portal; empty while unlocked or off.
|
||||
*/
|
||||
function withheldStalkerCategoryIds(
|
||||
parentalLock: ParentalLockService,
|
||||
playlist: PlaylistMeta | undefined,
|
||||
contentType: StalkerContentType
|
||||
): ReadonlySet<string> {
|
||||
const playlistId = playlist?._id;
|
||||
if (!playlistId || !parentalLock.active()) {
|
||||
return new Set();
|
||||
}
|
||||
if (parentalLock.withholdsEverything?.()) {
|
||||
return ALL_CATEGORIES_WITHHELD;
|
||||
}
|
||||
return new Set(parentalLock.lockedStalkerIds(playlistId, contentType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Content/categories/channels feature state.
|
||||
*/
|
||||
@@ -250,7 +277,8 @@ export function withStalkerContent() {
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
portalRepair = inject(StalkerPortalRepairService),
|
||||
translateService = inject(TranslateService),
|
||||
itvCache = inject(StalkerItvCacheService)
|
||||
itvCache = inject(StalkerItvCacheService),
|
||||
parentalLock = inject(ParentalLockService)
|
||||
) => {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerContentResourceStoreContract;
|
||||
@@ -261,6 +289,13 @@ export function withStalkerContent() {
|
||||
};
|
||||
|
||||
let lastLivePageKey = '';
|
||||
// Withheld (parental-locked) row ids seen while accumulating
|
||||
// the current list. A page that adds only withheld ids still
|
||||
// counts as progress, so paging continues past it; a page
|
||||
// adding nothing new — withheld or not — is a stalled portal.
|
||||
let withheldSeenKey = '';
|
||||
const withheldSeenIds = new Set<string>();
|
||||
let lastParentalLockVersion: number | null = null;
|
||||
return {
|
||||
categoryResource: resource({
|
||||
params: () => ({
|
||||
@@ -412,6 +447,10 @@ export function withStalkerContent() {
|
||||
(category) =>
|
||||
String(category.category_id) !== '*'
|
||||
).length,
|
||||
// Lock/unlock re-fires the loader: rows of a
|
||||
// locked genre are dropped at patch time, so the
|
||||
// list must be rebuilt when they become visible.
|
||||
parentalLockVersion: parentalLock.version(),
|
||||
}),
|
||||
loader: async ({
|
||||
params,
|
||||
@@ -457,15 +496,74 @@ export function withStalkerContent() {
|
||||
}
|
||||
|
||||
const categoryParam = params.category || '*';
|
||||
const withheldCategoryIds =
|
||||
withheldStalkerCategoryIds(
|
||||
parentalLock,
|
||||
playlist,
|
||||
params.contentType
|
||||
);
|
||||
// A lock flip while the list is past page 1 must
|
||||
// not append filtered rows onto pages that were
|
||||
// accumulated under the old lock state: drop the
|
||||
// withheld rows on screen now and restart from
|
||||
// page 1 so the list is rebuilt under the new one.
|
||||
if (
|
||||
lastParentalLockVersion !== null &&
|
||||
params.parentalLockVersion !==
|
||||
lastParentalLockVersion &&
|
||||
params.pageIndex > 1
|
||||
) {
|
||||
lastParentalLockVersion =
|
||||
params.parentalLockVersion;
|
||||
const retained = withoutWithheldStalkerItems(
|
||||
store.paginatedContent(),
|
||||
params.contentType,
|
||||
withheldCategoryIds
|
||||
);
|
||||
patchState(store, {
|
||||
paginatedContent: retained,
|
||||
...(params.contentType === 'itv'
|
||||
? {
|
||||
itvChannels:
|
||||
withoutWithheldStalkerItems(
|
||||
store.itvChannels(),
|
||||
'itv',
|
||||
withheldCategoryIds
|
||||
),
|
||||
}
|
||||
: params.contentType === 'radio'
|
||||
? {
|
||||
radioChannels:
|
||||
withoutWithheldStalkerItems(
|
||||
store.radioChannels(),
|
||||
'radio',
|
||||
withheldCategoryIds
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
(
|
||||
store as unknown as {
|
||||
setPage?: (page: number) => void;
|
||||
}
|
||||
).setPage?.(0);
|
||||
return retained;
|
||||
}
|
||||
lastParentalLockVersion =
|
||||
params.parentalLockVersion;
|
||||
|
||||
if (params.contentType === 'itv') {
|
||||
const cachedChannels =
|
||||
itvCache.getChannels(playlist);
|
||||
const channels =
|
||||
cachedChannels !== null
|
||||
? filterItvChannelsByGenre(
|
||||
cachedChannels,
|
||||
categoryParam
|
||||
? withoutWithheldStalkerItems(
|
||||
filterItvChannelsByGenre(
|
||||
cachedChannels,
|
||||
categoryParam
|
||||
),
|
||||
params.contentType,
|
||||
withheldCategoryIds
|
||||
)
|
||||
: null;
|
||||
// Serve from the cache only when it actually
|
||||
@@ -522,6 +620,8 @@ export function withStalkerContent() {
|
||||
params.pageIndex ===
|
||||
storeContext.page() + 1 &&
|
||||
paramsPlaylistKey === currentPlaylistKey &&
|
||||
params.parentalLockVersion ===
|
||||
parentalLock.version() &&
|
||||
// A legacy paged response must not overwrite
|
||||
// the full cached list that a re-fired
|
||||
// loader served in the meantime. Scoped
|
||||
@@ -609,12 +709,70 @@ export function withStalkerContent() {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newItems = response.js.data.map((item) =>
|
||||
const rawItems = response.js.data.map((item) =>
|
||||
toStalkerContentItem(
|
||||
item,
|
||||
playlist.portalUrl ?? ''
|
||||
)
|
||||
);
|
||||
const newItems = withoutWithheldStalkerItems(
|
||||
rawItems,
|
||||
params.contentType,
|
||||
withheldCategoryIds
|
||||
);
|
||||
const listKey = JSON.stringify([
|
||||
paramsPlaylistKey,
|
||||
params.contentType,
|
||||
params.category,
|
||||
params.search,
|
||||
]);
|
||||
if (
|
||||
params.pageIndex === 1 ||
|
||||
withheldSeenKey !== listKey
|
||||
) {
|
||||
withheldSeenKey = listKey;
|
||||
withheldSeenIds.clear();
|
||||
}
|
||||
let newWithheldCount = 0;
|
||||
if (newItems.length < rawItems.length) {
|
||||
const kept = new Set(newItems);
|
||||
for (const item of rawItems) {
|
||||
if (kept.has(item)) {
|
||||
continue;
|
||||
}
|
||||
const id = stalkerWithheldRowKey(item);
|
||||
if (!withheldSeenIds.has(id)) {
|
||||
withheldSeenIds.add(id);
|
||||
newWithheldCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A page made only of withheld rows would
|
||||
// leave the list unchanged; request the next
|
||||
// one so unlocked rows further on still load.
|
||||
const skipWithheldPage = (hasMore: boolean) => {
|
||||
if (
|
||||
!hasMore ||
|
||||
newItems.length > 0 ||
|
||||
newWithheldCount === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (isCurrentRequest()) {
|
||||
// `page` belongs to the selection
|
||||
// feature; the facade composes
|
||||
// its setter ahead of this one.
|
||||
(
|
||||
store as unknown as {
|
||||
setPage?: (
|
||||
page: number
|
||||
) => void;
|
||||
}
|
||||
).setPage?.(params.pageIndex);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (
|
||||
params.contentType === 'itv' ||
|
||||
@@ -645,6 +803,16 @@ export function withStalkerContent() {
|
||||
const replay =
|
||||
livePageKey === lastLivePageKey;
|
||||
lastLivePageKey = livePageKey;
|
||||
const hasMoreChannels =
|
||||
rawItems.length > 0 &&
|
||||
(params.pageIndex === 1 ||
|
||||
replay ||
|
||||
newWithheldCount > 0 ||
|
||||
nextChannels.length >
|
||||
existingChannels.length) &&
|
||||
nextChannels.length +
|
||||
withheldSeenIds.size <
|
||||
(response.js.total_items ?? 0);
|
||||
patchState(store, {
|
||||
totalCount:
|
||||
response.js.total_items ?? 0,
|
||||
@@ -662,15 +830,9 @@ export function withStalkerContent() {
|
||||
},
|
||||
}
|
||||
: { radioChannels: nextChannels }),
|
||||
hasMoreChannels:
|
||||
channels.length > 0 &&
|
||||
(params.pageIndex === 1 ||
|
||||
replay ||
|
||||
nextChannels.length >
|
||||
existingChannels.length) &&
|
||||
nextChannels.length <
|
||||
(response.js.total_items ?? 0),
|
||||
hasMoreChannels,
|
||||
});
|
||||
skipWithheldPage(hasMoreChannels);
|
||||
} else {
|
||||
// VOD/series pages accumulate into one
|
||||
// continuous list for the infinite-scroll
|
||||
@@ -694,18 +856,30 @@ export function withStalkerContent() {
|
||||
// end on every scroll crossing.
|
||||
const appendStalled =
|
||||
params.pageIndex > 1 &&
|
||||
newWithheldCount === 0 &&
|
||||
nextContent.length <=
|
||||
previousContent.length;
|
||||
// Withheld rows count against the portal's
|
||||
// total, or the grid would keep asking for
|
||||
// pages the lock will never let it show.
|
||||
const totalCount = appendStalled
|
||||
? nextContent.length
|
||||
: Math.max(
|
||||
0,
|
||||
(response.js.total_items ?? 0) -
|
||||
withheldSeenIds.size
|
||||
);
|
||||
|
||||
patchState(store, {
|
||||
totalCount: appendStalled
|
||||
? nextContent.length
|
||||
: (response.js.total_items ?? 0),
|
||||
totalCount,
|
||||
paginatedContent: nextContent,
|
||||
contentError: null,
|
||||
appendError: null,
|
||||
hasMoreChannels: false,
|
||||
});
|
||||
skipWithheldPage(
|
||||
nextContent.length < totalCount
|
||||
);
|
||||
return nextContent;
|
||||
}
|
||||
|
||||
@@ -744,16 +918,40 @@ export function withStalkerContent() {
|
||||
const storeContext = store as typeof store &
|
||||
StalkerContentResourceStoreContract;
|
||||
const itvCache = inject(StalkerItvCacheService);
|
||||
const parentalLock = inject(ParentalLockService);
|
||||
|
||||
/**
|
||||
* The whole portal's ITV channel list (all categories) when
|
||||
* cached. `versionFor` establishes the reactive dependency so this
|
||||
* cached, minus the genres the parental lock withholds.
|
||||
* `versionFor` establishes the reactive dependency so this
|
||||
* recomputes when the list becomes ready or is refreshed.
|
||||
*/
|
||||
const itvFullChannelList = computed(() => {
|
||||
const playlist = storeContext.currentPlaylist();
|
||||
itvCache.versionFor(playlist);
|
||||
return itvCache.getChannels(playlist) ?? [];
|
||||
parentalLock.version();
|
||||
return withoutWithheldStalkerItems(
|
||||
itvCache.getChannels(playlist) ?? [],
|
||||
'itv',
|
||||
withheldStalkerCategoryIds(parentalLock, playlist, 'itv')
|
||||
);
|
||||
});
|
||||
/** The selected section's genres with the withheld ones removed. */
|
||||
const visibleCategoryResource = computed(() => {
|
||||
parentalLock.version();
|
||||
const contentType = storeContext.selectedContentType();
|
||||
const withheld = withheldStalkerCategoryIds(
|
||||
parentalLock,
|
||||
storeContext.currentPlaylist(),
|
||||
contentType
|
||||
);
|
||||
const categories = getCategoriesByType(store, contentType);
|
||||
return withheld.size === 0
|
||||
? categories
|
||||
: categories.filter(
|
||||
(category) =>
|
||||
!withheld.has(String(category.category_id))
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -909,7 +1107,9 @@ export function withStalkerContent() {
|
||||
storeContext.getContentResource.isLoading()
|
||||
),
|
||||
isPaginatedContentFailed: computed(() => store.contentError()),
|
||||
getCategoryResource: computed(() =>
|
||||
getCategoryResource: visibleCategoryResource,
|
||||
/** Every genre of the section, locked ones included (lock dialog). */
|
||||
getAllCategoriesForSelectedType: computed(() =>
|
||||
getCategoriesByType(
|
||||
store,
|
||||
storeContext.selectedContentType()
|
||||
|
||||
@@ -4,4 +4,5 @@ export * from './stalker-playback-command.utils';
|
||||
export * from './stalker-player-request.utils';
|
||||
export * from './stalker-request.utils';
|
||||
export * from './stalker-content-mappers';
|
||||
export * from './stalker-parental-lock.utils';
|
||||
export * from './stalker-sorting';
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
isStalkerItemWithheld,
|
||||
stalkerWithheldRowKey,
|
||||
withoutWithheldStalkerItems,
|
||||
} from './stalker-parental-lock.utils';
|
||||
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
|
||||
|
||||
describe('stalker-parental-lock.utils', () => {
|
||||
const withheld = new Set(['7', '9']);
|
||||
|
||||
it('reads the genre from tv_genre_id for live and radio rows', () => {
|
||||
expect(isStalkerItemWithheld({ tv_genre_id: 7 }, 'itv', withheld)).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isStalkerItemWithheld({ tv_genre_id: '9' }, 'radio', withheld)
|
||||
).toBe(true);
|
||||
expect(isStalkerItemWithheld({ tv_genre_id: 8 }, 'itv', withheld)).toBe(
|
||||
false
|
||||
);
|
||||
// A VOD row with a matching category_id is not a live row.
|
||||
expect(
|
||||
isStalkerItemWithheld({ category_id: '7' }, 'itv', withheld)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reads category_id for VOD and series rows', () => {
|
||||
expect(
|
||||
isStalkerItemWithheld({ category_id: '7' }, 'vod', withheld)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStalkerItemWithheld({ category_id: 9 }, 'series', withheld)
|
||||
).toBe(true);
|
||||
expect(isStalkerItemWithheld({}, 'vod', withheld)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the same array when nothing is withheld', () => {
|
||||
const items = [{ category_id: '7' }];
|
||||
expect(withoutWithheldStalkerItems(items, 'vod', new Set())).toBe(
|
||||
items
|
||||
);
|
||||
expect(withoutWithheldStalkerItems(items, 'vod', withheld)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStalkerItemWithheld in fail-closed mode', () => {
|
||||
it('withholds rows without a genre only while everything is withheld', () => {
|
||||
expect(isStalkerItemWithheld({}, 'itv', ALL_CATEGORIES_WITHHELD)).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isStalkerItemWithheld(
|
||||
{ name: 'x' } as never,
|
||||
'vod',
|
||||
ALL_CATEGORIES_WITHHELD
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isStalkerItemWithheld({}, 'itv', new Set(['9']))).toBe(false);
|
||||
expect(
|
||||
withoutWithheldStalkerItems(
|
||||
[{ tv_genre_id: '1' }, {}],
|
||||
'itv',
|
||||
ALL_CATEGORIES_WITHHELD
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stalkerWithheldRowKey', () => {
|
||||
it('keys rows by whichever id field they carry, never by a shared empty string', () => {
|
||||
expect(stalkerWithheldRowKey({ id: 5 })).toBe('id:5');
|
||||
expect(stalkerWithheldRowKey({ stream_id: '6' })).toBe('id:6');
|
||||
expect(stalkerWithheldRowKey({ movie_id: 7 })).toBe('id:7');
|
||||
expect(stalkerWithheldRowKey({ series_id: 8 })).toBe('id:8');
|
||||
expect(stalkerWithheldRowKey({ cmd: 'ffmpeg http://a' })).not.toBe(
|
||||
stalkerWithheldRowKey({ cmd: 'ffmpeg http://b' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
|
||||
import type { StalkerContentType } from '../stalker-store.contracts';
|
||||
|
||||
/** Minimal item shape the parental lock needs: the category a row belongs to. */
|
||||
export interface StalkerLockableItem {
|
||||
tv_genre_id?: string | number;
|
||||
category_id?: string | number;
|
||||
id?: string | number;
|
||||
stream_id?: string | number;
|
||||
movie_id?: string | number;
|
||||
series_id?: string | number;
|
||||
cmd?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key the paging bookkeeping records a withheld row under. Stalker rows
|
||||
* carry their id in one of several fields; keying every id-less row under
|
||||
* `''` would count only the first of them as new, and a page of new locked
|
||||
* rows would then look like a stalled portal and end paging short of later
|
||||
* visible matches.
|
||||
*/
|
||||
export function stalkerWithheldRowKey(item: StalkerLockableItem): string {
|
||||
const id = item.id ?? item.stream_id ?? item.movie_id ?? item.series_id;
|
||||
if (id !== undefined && id !== null && String(id) !== '') {
|
||||
return `id:${String(id)}`;
|
||||
}
|
||||
return `row:${String(item.cmd ?? item.name ?? '')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an item belongs to one of the withheld categories. Live and radio
|
||||
* rows carry their genre in `tv_genre_id`; VOD and series rows carry
|
||||
* `category_id`. Only matters for portal-wide lists ("All" and search),
|
||||
* since a withheld category can never be selected on its own.
|
||||
*/
|
||||
export function isStalkerItemWithheld(
|
||||
item: StalkerLockableItem,
|
||||
contentType: StalkerContentType,
|
||||
withheldCategoryIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
if (withheldCategoryIds.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const categoryId =
|
||||
contentType === 'itv' || contentType === 'radio'
|
||||
? item.tv_genre_id
|
||||
: item.category_id;
|
||||
if (categoryId === undefined || categoryId === null) {
|
||||
// A row without a genre is visible under a normal lock set but not
|
||||
// in fail-closed mode: while the locks are unknown, "no genre" must
|
||||
// not become the one row the withheld catalog still shows.
|
||||
return withheldCategoryIds === ALL_CATEGORIES_WITHHELD;
|
||||
}
|
||||
return withheldCategoryIds.has(String(categoryId));
|
||||
}
|
||||
|
||||
export function withoutWithheldStalkerItems<T extends StalkerLockableItem>(
|
||||
items: T[],
|
||||
contentType: StalkerContentType,
|
||||
withheldCategoryIds: ReadonlySet<string>
|
||||
): T[] {
|
||||
if (withheldCategoryIds.size === 0) {
|
||||
return items;
|
||||
}
|
||||
return items.filter(
|
||||
(item) => !isStalkerItemWithheld(item, contentType, withheldCategoryIds)
|
||||
);
|
||||
}
|
||||
@@ -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,87 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
convertToParamMap,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
UrlTree,
|
||||
} from '@angular/router';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { parentalLockStalkerCategoryGuard } from './parental-lock-category.guard';
|
||||
|
||||
function createRoute(playlistId: string, categoryId: string) {
|
||||
const parent = {
|
||||
paramMap: convertToParamMap({ id: playlistId }),
|
||||
} as ActivatedRouteSnapshot;
|
||||
const route = {
|
||||
paramMap: convertToParamMap({ categoryId }),
|
||||
} as ActivatedRouteSnapshot;
|
||||
Object.defineProperty(route, 'pathFromRoot', {
|
||||
value: [parent, route],
|
||||
});
|
||||
return route;
|
||||
}
|
||||
|
||||
describe('parentalLockStalkerCategoryGuard', () => {
|
||||
let parentalLock: {
|
||||
initialize: jest.Mock;
|
||||
active: jest.Mock;
|
||||
isStalkerCategoryLocked: jest.Mock;
|
||||
requestUnlock: jest.Mock;
|
||||
};
|
||||
let router: { createUrlTree: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
parentalLock = {
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
active: jest.fn(() => true),
|
||||
isStalkerCategoryLocked: jest.fn(() => false),
|
||||
requestUnlock: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
router = { createUrlTree: jest.fn(() => ({}) as UrlTree) };
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{ provide: Router, useValue: router },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
function run(section: 'vod' | 'series', categoryId: string) {
|
||||
return TestBed.runInInjectionContext(() =>
|
||||
parentalLockStalkerCategoryGuard(section)(
|
||||
createRoute('portal-1', categoryId),
|
||||
{} as RouterStateSnapshot
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
it('passes an unlocked genre and an inactive lock', async () => {
|
||||
await expect(run('vod', '9')).resolves.toBe(true);
|
||||
parentalLock.active.mockReturnValue(false);
|
||||
parentalLock.isStalkerCategoryLocked.mockReturnValue(true);
|
||||
await expect(run('vod', '9')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('prompts for a locked genre and redirects to the section root on refusal', async () => {
|
||||
parentalLock.isStalkerCategoryLocked.mockReturnValue(true);
|
||||
|
||||
const result = await run('series', '9');
|
||||
|
||||
expect(parentalLock.isStalkerCategoryLocked).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
'series',
|
||||
'9'
|
||||
);
|
||||
expect(router.createUrlTree).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'stalker',
|
||||
'portal-1',
|
||||
'series',
|
||||
]);
|
||||
expect(result).not.toBe(true);
|
||||
|
||||
parentalLock.requestUnlock.mockResolvedValue(true);
|
||||
await expect(run('series', '9')).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { inject } from '@angular/core';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
CanActivateFn,
|
||||
Router,
|
||||
UrlTree,
|
||||
} from '@angular/router';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { ParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
|
||||
|
||||
function findPlaylistId(route: ActivatedRouteSnapshot): string | null {
|
||||
for (const snapshot of route.pathFromRoot) {
|
||||
const id = snapshot.paramMap.get('id');
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a parental-locked Stalker genre off the screen when reached by URL.
|
||||
* Stalker routes carry the portal's own genre id, which is what the lock
|
||||
* store keys on, so no lookup is needed. A locked genre prompts for the PIN;
|
||||
* a refusal redirects to the section root.
|
||||
*/
|
||||
export function parentalLockStalkerCategoryGuard(
|
||||
section: ParentalLockStalkerCategoryType
|
||||
): CanActivateFn {
|
||||
return async (route): Promise<boolean | UrlTree> => {
|
||||
const parentalLock = inject(ParentalLockService);
|
||||
const router = inject(Router);
|
||||
|
||||
await parentalLock.initialize();
|
||||
if (!parentalLock.active()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const playlistId = findPlaylistId(route);
|
||||
const categoryId = route.paramMap.get('categoryId');
|
||||
if (
|
||||
!playlistId ||
|
||||
!categoryId ||
|
||||
!parentalLock.isStalkerCategoryLocked(
|
||||
playlistId,
|
||||
section,
|
||||
categoryId
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (await parentalLock.requestUnlock()) {
|
||||
return true;
|
||||
}
|
||||
return router.createUrlTree([
|
||||
'/workspace',
|
||||
'stalker',
|
||||
playlistId,
|
||||
section,
|
||||
]);
|
||||
};
|
||||
}
|
||||
+105
@@ -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>
|
||||
+161
@@ -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;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
StalkerCategoryLockDialogComponent,
|
||||
StalkerCategoryLockDialogData,
|
||||
} from './stalker-category-lock-dialog.component';
|
||||
|
||||
describe('StalkerCategoryLockDialogComponent', () => {
|
||||
let fixture: ComponentFixture<StalkerCategoryLockDialogComponent>;
|
||||
let component: StalkerCategoryLockDialogComponent;
|
||||
const dialogRef = { close: jest.fn() };
|
||||
const parentalLock = {
|
||||
active: signal(false),
|
||||
lockedStalkerIds: jest.fn(() => ['2']),
|
||||
setStalkerLocks: jest.fn(),
|
||||
};
|
||||
const data: StalkerCategoryLockDialogData = {
|
||||
playlistId: 'stalker-1',
|
||||
contentType: 'itv',
|
||||
categories: [
|
||||
{ category_id: '1', category_name: 'News', censored: false },
|
||||
{ category_id: '2', category_name: 'Adult', censored: true },
|
||||
] as StalkerCategoryLockDialogData['categories'],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
parentalLock.active.set(false);
|
||||
parentalLock.setStalkerLocks.mockResolvedValue(true);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
StalkerCategoryLockDialogComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(StalkerCategoryLockDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('seeds the draft from the stored locks', () => {
|
||||
expect(component.categories().map((c) => c.locked)).toEqual([
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
expect(component.lockedCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('closes without saving when the session relocks while it is open', () => {
|
||||
expect(dialogRef.close).not.toHaveBeenCalled();
|
||||
|
||||
parentalLock.active.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(false);
|
||||
expect(parentalLock.setStalkerLocks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
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
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Same relock rule as the Xtream editor: the dialog outliving the
|
||||
// unlocked session would keep the locked names and Save reachable.
|
||||
effect(() => {
|
||||
if (this.parentalLock.active()) {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { parentalLockStalkerCategoryGuard } from './parental-lock-category.guard';
|
||||
import { PORTAL_CATALOG_DETAIL_COMPONENT } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail/stalker-catalog-detail.component';
|
||||
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
|
||||
@@ -77,6 +78,9 @@ export function createStalkerRoutes(): Route[] {
|
||||
},
|
||||
{
|
||||
path: ':categoryId',
|
||||
canActivate: [
|
||||
parentalLockStalkerCategoryGuard('vod'),
|
||||
],
|
||||
data: {
|
||||
api: 'stalker',
|
||||
contentType: 'vod',
|
||||
@@ -113,6 +117,9 @@ export function createStalkerRoutes(): Route[] {
|
||||
},
|
||||
{
|
||||
path: ':categoryId',
|
||||
canActivate: [
|
||||
parentalLockStalkerCategoryGuard('series'),
|
||||
],
|
||||
data: {
|
||||
api: 'stalker',
|
||||
contentType: 'series',
|
||||
|
||||
+1
-4
@@ -20,10 +20,7 @@ describe('StalkerItvAllItemsComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
StalkerItvAllItemsComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
imports: [StalkerItvAllItemsComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StalkerItvAllItemsComponent);
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { isStalkerPlaybackRequestLockCurrent } from './stalker-live-lock-guard';
|
||||
|
||||
describe('isStalkerPlaybackRequestLockCurrent', () => {
|
||||
it('lets an unchanged lock version through', () => {
|
||||
expect(isStalkerPlaybackRequestLockCurrent(3, 3, true)).toBe(true);
|
||||
expect(isStalkerPlaybackRequestLockCurrent(3, 3, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('retires a request the session relocked over', () => {
|
||||
expect(isStalkerPlaybackRequestLockCurrent(3, 4, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a request through when the change was an unlock', () => {
|
||||
expect(isStalkerPlaybackRequestLockCurrent(3, 4, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Whether a playback request issued under parental-lock `requested` may
|
||||
* still complete now that the lock is at `current`. The embedded player
|
||||
* defers selecting the channel until its stream resolves, so the
|
||||
* enforcement service's clearing of the selection cannot retire the
|
||||
* request; a relock in between must. An UNLOCK in between is harmless — the
|
||||
* request completes.
|
||||
*/
|
||||
export function isStalkerPlaybackRequestLockCurrent(
|
||||
requested: number,
|
||||
current: number,
|
||||
active: boolean
|
||||
): boolean {
|
||||
return requested === current || !active;
|
||||
}
|
||||
+34
-26
@@ -20,6 +20,7 @@ import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
@@ -283,6 +284,10 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
},
|
||||
{ provide: PlaylistsService, useValue: playlistService },
|
||||
{ provide: SettingsStore, useValue: settingsStore },
|
||||
{
|
||||
provide: ParentalLockService,
|
||||
useValue: { version: signal(0), active: signal(false) },
|
||||
},
|
||||
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
||||
{
|
||||
provide: TranslateService,
|
||||
@@ -782,35 +787,38 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, false])('grows the render window for remote/numeric selection (cached=%s)', async (cached) => {
|
||||
const full = Array.from({ length: 250 }, (_, index) => ({
|
||||
id: `ch-${index}`,
|
||||
cmd: `ffrt4://itv/${index}`,
|
||||
name: `Channel ${index}`,
|
||||
o_name: `Channel ${index}`,
|
||||
logo: '',
|
||||
}));
|
||||
itvFullListActive.set(true);
|
||||
itvSelectedCategoryFromCache.set(cached);
|
||||
itvChannels.set(full);
|
||||
itvFullChannelList.set(cached ? full : []);
|
||||
searchPhrase.set('channel');
|
||||
fixture.detectChanges();
|
||||
it.each([true, false])(
|
||||
'grows the render window for remote/numeric selection (cached=%s)',
|
||||
async (cached) => {
|
||||
const full = Array.from({ length: 250 }, (_, index) => ({
|
||||
id: `ch-${index}`,
|
||||
cmd: `ffrt4://itv/${index}`,
|
||||
name: `Channel ${index}`,
|
||||
o_name: `Channel ${index}`,
|
||||
logo: '',
|
||||
}));
|
||||
itvFullListActive.set(true);
|
||||
itvSelectedCategoryFromCache.set(cached);
|
||||
itvChannels.set(full);
|
||||
itvFullChannelList.set(cached ? full : []);
|
||||
searchPhrase.set('channel');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.visibleChannels()).toHaveLength(100);
|
||||
expect(component.visibleChannels()).toHaveLength(100);
|
||||
|
||||
// Selecting channel index 150 (outside the 100-item window) must grow
|
||||
// the window so it is rendered and can be highlighted/scrolled to.
|
||||
await component.playChannel(full[150], false);
|
||||
fixture.detectChanges();
|
||||
// Selecting channel index 150 (outside the 100-item window) must grow
|
||||
// the window so it is rendered and can be highlighted/scrolled to.
|
||||
await component.playChannel(full[150], false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.visibleChannels().length).toBeGreaterThan(150);
|
||||
expect(
|
||||
component
|
||||
.visibleChannels()
|
||||
.some((channel) => channel.id === 'ch-150')
|
||||
).toBe(true);
|
||||
});
|
||||
expect(component.visibleChannels().length).toBeGreaterThan(150);
|
||||
expect(
|
||||
component
|
||||
.visibleChannels()
|
||||
.some((channel) => channel.id === 'ch-150')
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not clear cached channels when switching category in full-list mode', async () => {
|
||||
// Regression: the category-change reset effect used to setItvChannels([])
|
||||
|
||||
+32
-3
@@ -37,11 +37,13 @@ import {
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/ui/components';
|
||||
import {
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
RecordingsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { isStalkerPlaybackRequestLockCurrent } from './stalker-live-lock-guard';
|
||||
import {
|
||||
foldSearchText,
|
||||
buildStalkerEpgMappingKey,
|
||||
@@ -125,6 +127,8 @@ interface StalkerPlaybackResolutionOwner {
|
||||
readonly sourceId: string;
|
||||
readonly contentType: string;
|
||||
readonly channelId: string;
|
||||
/** `ParentalLockService.version` when the request was issued. */
|
||||
readonly lockVersion: number;
|
||||
}
|
||||
|
||||
/** Channels rendered per "page" when the full list is served from the cache. */
|
||||
@@ -181,6 +185,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
implements OnDestroy, FullscreenChannelPanelHost
|
||||
{
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
@@ -643,6 +648,22 @@ export class StalkerLiveStreamLayoutComponent
|
||||
30_000
|
||||
);
|
||||
|
||||
// The parental lock clears the store selection from outside this
|
||||
// layout when the playing channel's genre is withheld
|
||||
// (ParentalLockEnforcementService). The template mounts the player
|
||||
// only with a selection, so the playback held here is dropped too
|
||||
// instead of resurfacing with the next selection.
|
||||
effect(() => {
|
||||
if (this.stalkerStore.selectedItem()) {
|
||||
return;
|
||||
}
|
||||
untracked(() => {
|
||||
if (this.activePlayback()) {
|
||||
this.clearActivePlayback();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Load favorites for current playlist
|
||||
const playlistId = this.stalkerStore.currentPlaylist()?._id;
|
||||
if (playlistId) {
|
||||
@@ -914,6 +935,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
this.stalkerStore.currentPlaylist()?._id
|
||||
);
|
||||
const contentType = this.stalkerStore.selectedContentType();
|
||||
const lockVersion = this.parentalLock.version();
|
||||
const isRadioMode = this.isRadioMode();
|
||||
const deferSelection = !isRadioMode && this.usesEmbeddedPlayer();
|
||||
// Inline video retains its stream during resolution. Keep selection,
|
||||
@@ -931,10 +953,10 @@ export class StalkerLiveStreamLayoutComponent
|
||||
try {
|
||||
const playback = await this.resolvePlaybackForChannel(
|
||||
item,
|
||||
{ sourceId, contentType, channelId },
|
||||
{ sourceId, contentType, channelId, lockVersion },
|
||||
isRadioMode
|
||||
);
|
||||
const owner = { sourceId, contentType, channelId };
|
||||
const owner = { sourceId, contentType, channelId, lockVersion };
|
||||
if (
|
||||
!this.isPlaybackRequestCurrent(
|
||||
requestId,
|
||||
@@ -996,7 +1018,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
if (
|
||||
!this.isPlaybackRequestCurrent(
|
||||
requestId,
|
||||
{ sourceId, contentType, channelId },
|
||||
{ sourceId, contentType, channelId, lockVersion },
|
||||
expectedSelectedId
|
||||
)
|
||||
) {
|
||||
@@ -1046,6 +1068,13 @@ export class StalkerLiveStreamLayoutComponent
|
||||
): boolean {
|
||||
return (
|
||||
requestId === this.playbackRequestId &&
|
||||
// A relock while the stream resolved: the deferred selection
|
||||
// means the enforcement service had nothing to clear yet.
|
||||
isStalkerPlaybackRequestLockCurrent(
|
||||
owner.lockVersion,
|
||||
this.parentalLock.version(),
|
||||
this.parentalLock.active()
|
||||
) &&
|
||||
this.selectedChannelId() === expectedSelectedId &&
|
||||
normalizeStalkerEntityId(
|
||||
this.stalkerStore.currentPlaylist()?._id
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { isStalkerSearchRequestCurrent } from './stalker-search-request.util';
|
||||
|
||||
describe('isStalkerSearchRequestCurrent', () => {
|
||||
const key = {
|
||||
search: 'family',
|
||||
contentType: 'vod',
|
||||
page: 2,
|
||||
playlistId: 'stalker-1',
|
||||
parentalLockVersion: 3,
|
||||
};
|
||||
|
||||
it('accepts a response whose coordinates are unchanged', () => {
|
||||
expect(isStalkerSearchRequestCurrent(key, { ...key })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a response issued under an older parental lock version', () => {
|
||||
expect(
|
||||
isStalkerSearchRequestCurrent(key, {
|
||||
...key,
|
||||
parentalLockVersion: 4,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['search', { search: 'famil' }],
|
||||
['contentType', { contentType: 'series' }],
|
||||
['page', { page: 3 }],
|
||||
['playlistId', { playlistId: 'stalker-2' }],
|
||||
])('rejects a response whose %s moved on', (_field, change) => {
|
||||
expect(isStalkerSearchRequestCurrent(key, { ...key, ...change })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/** The coordinates a Stalker search page was requested under. */
|
||||
export interface StalkerSearchRequestKey {
|
||||
readonly search: string;
|
||||
readonly contentType: string;
|
||||
readonly page: number;
|
||||
readonly playlistId: string | null;
|
||||
/** `ParentalLockService.version` at request time. */
|
||||
readonly parentalLockVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a search response may still be applied. Portal requests are not
|
||||
* aborted, so a page issued under an older state can finish after a newer
|
||||
* one; the parental lock version is part of the key because an older
|
||||
* response was filtered with the OLDER withheld set — applying it after a
|
||||
* relock would repopulate the grid with locked-category rows.
|
||||
*/
|
||||
export function isStalkerSearchRequestCurrent(
|
||||
requested: StalkerSearchRequestKey,
|
||||
current: StalkerSearchRequestKey
|
||||
): boolean {
|
||||
return (
|
||||
requested.search === current.search &&
|
||||
requested.contentType === current.contentType &&
|
||||
requested.page === current.page &&
|
||||
requested.playlistId === current.playlistId &&
|
||||
requested.parentalLockVersion === current.parentalLockVersion
|
||||
);
|
||||
}
|
||||
+131
-13
@@ -24,8 +24,15 @@ import {
|
||||
StalkerStore,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import { DataService, PlaylistsService } from '@iptvnator/services';
|
||||
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
DataService,
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
ALL_CATEGORIES_WITHHELD,
|
||||
CONNECTIVITY_GUARD_RESET,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerSearchComponent } from './stalker-search.component';
|
||||
|
||||
@@ -289,6 +296,37 @@ describe('StalkerSearchComponent playback session key', () => {
|
||||
describe('StalkerSearchComponent result paging', () => {
|
||||
let component: StalkerSearchComponent;
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
let parentalLock: {
|
||||
active: jest.Mock<boolean, []>;
|
||||
version: ReturnType<typeof signal<number>>;
|
||||
lockedStalkerIds: jest.Mock<string[], [string, string]>;
|
||||
};
|
||||
let stalkerStoreMock: {
|
||||
selectedItem: ReturnType<typeof signal<unknown>>;
|
||||
setSelectedContentType: jest.Mock;
|
||||
setSelectedItem: jest.Mock;
|
||||
addToFavorites: jest.Mock;
|
||||
removeFromFavorites: jest.Mock;
|
||||
resolveVodPlayback: jest.Mock;
|
||||
};
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, attempts = 40) {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
await flush();
|
||||
}
|
||||
throw new Error('Timed out waiting for the search resource');
|
||||
}
|
||||
const activePlaylist = signal({
|
||||
_id: 'playlist|one',
|
||||
title: 'Search portal',
|
||||
@@ -307,6 +345,19 @@ describe('StalkerSearchComponent result paging', () => {
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
parentalLock = {
|
||||
active: jest.fn(() => false),
|
||||
version: signal(0),
|
||||
lockedStalkerIds: jest.fn(() => []),
|
||||
};
|
||||
stalkerStoreMock = {
|
||||
selectedItem: signal(null),
|
||||
setSelectedContentType: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
resolveVodPlayback: jest.fn(),
|
||||
};
|
||||
activePlaylist.set({
|
||||
_id: 'playlist|one',
|
||||
title: 'Search portal',
|
||||
@@ -336,17 +387,8 @@ describe('StalkerSearchComponent result paging', () => {
|
||||
provide: PlaylistsService,
|
||||
useValue: { getPortalFavorites: () => of([]) },
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: {
|
||||
selectedItem: signal(null),
|
||||
setSelectedContentType: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
addToFavorites: jest.fn(),
|
||||
removeFromFavorites: jest.fn(),
|
||||
resolveVodPlayback: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: StalkerStore, useValue: stalkerStoreMock },
|
||||
{ provide: ParentalLockService, useValue: parentalLock },
|
||||
{ provide: StalkerSessionService, useValue: {} },
|
||||
{ provide: StalkerPortalRepairService, useValue: {} },
|
||||
{
|
||||
@@ -403,6 +445,82 @@ describe('StalkerSearchComponent result paging', () => {
|
||||
expect(component.searchHasMore()).toBe(false);
|
||||
});
|
||||
|
||||
it('advances past a page made only of parental-locked rows, but not past a repeated one', async () => {
|
||||
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
|
||||
const pageBefore = component.searchPage();
|
||||
|
||||
// Locked rows the list had not seen: schedule the next page.
|
||||
component.advancePastWithheldPage(pageBefore, 0, 2, () => true);
|
||||
await flushMicrotasks();
|
||||
expect(component.searchPage()).toBe(pageBefore + 1);
|
||||
|
||||
// A page with visible rows, or one adding no new withheld ids, or a
|
||||
// request that is no longer current: stay put.
|
||||
component.advancePastWithheldPage(pageBefore + 1, 1, 2, () => true);
|
||||
component.advancePastWithheldPage(pageBefore + 1, 0, 0, () => true);
|
||||
component.advancePastWithheldPage(pageBefore + 1, 0, 2, () => false);
|
||||
await flushMicrotasks();
|
||||
expect(component.searchPage()).toBe(pageBefore + 1);
|
||||
});
|
||||
|
||||
it('drops withheld rows on screen at relock time, page 1 included', () => {
|
||||
component.applySearchPageSuccess(
|
||||
1,
|
||||
[
|
||||
{ id: 'news-1', name: 'News', category_id: '5' },
|
||||
{ id: 'adult-1', name: 'Adult', category_id: '9' },
|
||||
],
|
||||
2
|
||||
);
|
||||
expect(component.searchResults()).toHaveLength(2);
|
||||
|
||||
component.applyRelockToResults(new Set(['9']), 'vod');
|
||||
|
||||
expect(component.searchResults().map((item) => item.id)).toEqual([
|
||||
'news-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes an open detail whose genre became withheld on relock', () => {
|
||||
component.selectItem({ id: 'adult-9', name: 'A', category_id: '9' });
|
||||
expect(component.itemDetails()).not.toBeNull();
|
||||
|
||||
// Another genre locked: the detail stays.
|
||||
component.closeWithheldDetail(new Set(['5']));
|
||||
expect(component.itemDetails()).not.toBeNull();
|
||||
|
||||
component.closeWithheldDetail(new Set(['9']));
|
||||
expect(component.itemDetails()).toBeNull();
|
||||
expect(component.vodDetailsItem()).toBeNull();
|
||||
expect(stalkerStoreMock.setSelectedItem).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('closes a genre-less detail on relock only in fail-closed mode', () => {
|
||||
component.selectItem({ id: 'no-genre', name: 'N' });
|
||||
expect(component.itemDetails()).not.toBeNull();
|
||||
|
||||
component.closeWithheldDetail(new Set(['9']));
|
||||
expect(component.itemDetails()).not.toBeNull();
|
||||
|
||||
component.closeWithheldDetail(ALL_CATEGORIES_WITHHELD);
|
||||
expect(component.itemDetails()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps paging past a page whose rows were all withheld by the parental lock', () => {
|
||||
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
|
||||
expect(component.searchHasMore()).toBe(true);
|
||||
|
||||
// The portal sent rows, every one of them locked: no visible growth,
|
||||
// but not the end of the results either.
|
||||
component.applySearchPageSuccess(2, [], 10, true);
|
||||
expect(component.searchResults()).toHaveLength(3);
|
||||
expect(component.searchHasMore()).toBe(true);
|
||||
|
||||
// An actually empty page still ends it.
|
||||
component.applySearchPageSuccess(3, [], 10, false);
|
||||
expect(component.searchHasMore()).toBe(false);
|
||||
});
|
||||
|
||||
it('stops paging when a total-backed append makes no progress', () => {
|
||||
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
|
||||
expect(component.searchHasMore()).toBe(true);
|
||||
|
||||
@@ -21,13 +21,17 @@ import {
|
||||
executeStalkerRequest,
|
||||
StalkerPortalRepairService,
|
||||
StalkerSessionService,
|
||||
stalkerWithheldRowKey,
|
||||
withoutWithheldStalkerItems,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
DataService,
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
resetHostConnectivityGuard,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
ALL_CATEGORIES_WITHHELD,
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
StalkerPortalActions,
|
||||
@@ -66,6 +70,7 @@ import {
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import { isStalkerSearchRequestCurrent } from './stalker-search-request.util';
|
||||
|
||||
interface StalkerFilter {
|
||||
key: StalkerSearchContentType;
|
||||
@@ -120,6 +125,16 @@ export class StalkerSearchComponent {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly location = inject(Location);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
/** Lock version the accumulated results were built under. */
|
||||
private searchResultsLockVersion: number | null = null;
|
||||
/**
|
||||
* Withheld row ids seen for the current search identity. A page adding
|
||||
* only new withheld ids is still progress and is skipped automatically;
|
||||
* a page adding nothing new is the end of the results.
|
||||
*/
|
||||
private searchWithheldKey = '';
|
||||
private readonly searchWithheldIds = new Set<string>();
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
@@ -241,6 +256,9 @@ export class StalkerSearchComponent {
|
||||
page: this.searchPage(),
|
||||
playlistId: this.currentPlaylist()?._id ?? null,
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
// Lock/unlock re-fires the search: withheld rows are dropped at
|
||||
// page time, so the results must be rebuilt when they change.
|
||||
parentalLockVersion: this.parentalLock.version(),
|
||||
}),
|
||||
loader: async ({ params }) => {
|
||||
if (params.search.length < 3) {
|
||||
@@ -260,6 +278,43 @@ export class StalkerSearchComponent {
|
||||
return [];
|
||||
}
|
||||
const contentType = params.contentType;
|
||||
// The dedicated search route has no category guard, so it filters
|
||||
// the portal's rows itself: a locked genre's title must not reach
|
||||
// the grid, its detail or playback through search.
|
||||
const withheldCategoryIds: ReadonlySet<string> =
|
||||
!this.parentalLock.active()
|
||||
? new Set<string>()
|
||||
: this.parentalLock.withholdsEverything?.()
|
||||
? ALL_CATEGORIES_WITHHELD
|
||||
: new Set(
|
||||
this.parentalLock.lockedStalkerIds(
|
||||
playlist._id,
|
||||
contentType
|
||||
)
|
||||
);
|
||||
const lockVersionChanged =
|
||||
this.searchResultsLockVersion !== null &&
|
||||
this.searchResultsLockVersion !== params.parentalLockVersion;
|
||||
this.searchResultsLockVersion = params.parentalLockVersion;
|
||||
if (lockVersionChanged) {
|
||||
this.applyRelockToResults(withheldCategoryIds, contentType);
|
||||
}
|
||||
if (lockVersionChanged && params.page > 1) {
|
||||
// A lock flip past page 1: rebuild from page 1 rather than
|
||||
// appending to pages accumulated under the old lock state.
|
||||
this.searchPage.set(1);
|
||||
return this.accumulatedSearchResults();
|
||||
}
|
||||
const withheldKey = JSON.stringify([
|
||||
params.playlistId,
|
||||
contentType,
|
||||
params.search,
|
||||
params.parentalLockVersion,
|
||||
]);
|
||||
if (params.page === 1 || this.searchWithheldKey !== withheldKey) {
|
||||
this.searchWithheldKey = withheldKey;
|
||||
this.searchWithheldIds.clear();
|
||||
}
|
||||
|
||||
// Mirror the catalog request shape: many Ministra portals
|
||||
// return an empty list for get_ordered_list without the
|
||||
@@ -278,13 +333,18 @@ export class StalkerSearchComponent {
|
||||
...(contentType === 'vod' ? { genre: '0' } : {}),
|
||||
};
|
||||
|
||||
// A stale response (term/filter/page/portal moved on while this
|
||||
// page was in flight) must not clobber the accumulated list.
|
||||
// A stale response (term/filter/page/portal — or the parental
|
||||
// lock — moved on while this page was in flight) must not clobber
|
||||
// the accumulated list: the request is not aborted, and a
|
||||
// pre-relock response was filtered with the pre-relock set.
|
||||
const isCurrent = (): boolean =>
|
||||
params.search === this.searchTerm() &&
|
||||
params.contentType === this.selectedFilterType() &&
|
||||
params.page === this.searchPage() &&
|
||||
params.playlistId === (this.currentPlaylist()?._id ?? null);
|
||||
isStalkerSearchRequestCurrent(params, {
|
||||
search: this.searchTerm(),
|
||||
contentType: this.selectedFilterType(),
|
||||
page: this.searchPage(),
|
||||
playlistId: this.currentPlaylist()?._id ?? null,
|
||||
parentalLockVersion: this.parentalLock.version(),
|
||||
});
|
||||
|
||||
try {
|
||||
// executeStalkerRequest owns the portal-mode decision (shared
|
||||
@@ -301,20 +361,53 @@ export class StalkerSearchComponent {
|
||||
playlist,
|
||||
requestParams
|
||||
);
|
||||
const items = (response.js?.data || []).map(
|
||||
const rawItems = (response.js?.data || []).map(
|
||||
(item: StalkerVodSource) =>
|
||||
this.processItemUrls(item, portalUrl)
|
||||
);
|
||||
|
||||
const items = withoutWithheldStalkerItems(
|
||||
rawItems,
|
||||
contentType,
|
||||
withheldCategoryIds
|
||||
);
|
||||
// Before the withheld-id bookkeeping: a stale page must not
|
||||
// pre-record ids into a set a newer relock request cleared,
|
||||
// or that request's page counts no new withheld rows and
|
||||
// stops paging short of later visible matches.
|
||||
if (!isCurrent()) {
|
||||
return items;
|
||||
}
|
||||
let newWithheldCount = 0;
|
||||
if (items.length < rawItems.length) {
|
||||
const kept = new Set(items);
|
||||
for (const item of rawItems) {
|
||||
const id = stalkerWithheldRowKey(item);
|
||||
if (
|
||||
!kept.has(item) &&
|
||||
!this.searchWithheldIds.has(id)
|
||||
) {
|
||||
this.searchWithheldIds.add(id);
|
||||
newWithheldCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.applySearchPageSuccess(
|
||||
const merged = this.applySearchPageSuccess(
|
||||
params.page,
|
||||
items,
|
||||
response.js?.total_items
|
||||
response.js?.total_items,
|
||||
// A page made only of withheld rows still is a page the
|
||||
// portal served; judge progress on what it sent.
|
||||
rawItems.length > 0 &&
|
||||
(items.length > 0 || newWithheldCount > 0)
|
||||
);
|
||||
this.advancePastWithheldPage(
|
||||
params.page,
|
||||
items.length,
|
||||
newWithheldCount,
|
||||
isCurrent
|
||||
);
|
||||
return merged;
|
||||
} catch (error) {
|
||||
this.logger.warn('Stalker search page failed', {
|
||||
page: params.page,
|
||||
@@ -343,7 +436,8 @@ export class StalkerSearchComponent {
|
||||
applySearchPageSuccess(
|
||||
page: number,
|
||||
items: StalkerVodSource[],
|
||||
totalItems: number | undefined
|
||||
totalItems: number | undefined,
|
||||
pageHadRows: boolean = items.length > 0
|
||||
): StalkerVodSource[] {
|
||||
const previous = page === 1 ? [] : this.accumulatedSearchResults();
|
||||
const merged =
|
||||
@@ -352,13 +446,16 @@ export class StalkerSearchComponent {
|
||||
// a reported total. Dedup after mid-list portal mutations can leave
|
||||
// the unique list permanently shorter than total_items, and a
|
||||
// repeated page dedupes to no growth; either way a no-progress
|
||||
// append is the practical end of the results.
|
||||
const madeProgress = page === 1 || merged.length > previous.length;
|
||||
// append is the practical end of the results. A page whose rows were
|
||||
// all withheld by the parental lock counts as progress too.
|
||||
const withheldRows = pageHadRows && items.length === 0;
|
||||
const madeProgress =
|
||||
page === 1 || merged.length > previous.length || withheldRows;
|
||||
this.searchHasMore.set(
|
||||
madeProgress &&
|
||||
(typeof totalItems === 'number' && totalItems >= 0
|
||||
? merged.length < totalItems
|
||||
: items.length > 0)
|
||||
: pageHadRows)
|
||||
);
|
||||
this.searchAppendError.set(false);
|
||||
this.accumulatedSearchResults.set(merged);
|
||||
@@ -605,6 +702,84 @@ export class StalkerSearchComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The infinite scroll gives up after a few loads that add no height, so
|
||||
* a run of pages made only of parental-locked rows must advance by
|
||||
* itself until a visible row (or the real end) is reached. Only a page
|
||||
* that added withheld ids not seen before counts — a stalled portal
|
||||
* repeating the same locked rows must still end the loop.
|
||||
*/
|
||||
advancePastWithheldPage(
|
||||
page: number,
|
||||
visibleCount: number,
|
||||
newWithheldCount: number,
|
||||
isCurrent: () => boolean
|
||||
): void {
|
||||
if (
|
||||
visibleCount > 0 ||
|
||||
newWithheldCount === 0 ||
|
||||
!this.searchHasMore()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (isCurrent()) {
|
||||
this.searchPage.set(page + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the open detail when its genre is withheld by the parental
|
||||
* lock (Lock now, idle relock): the title, its playback actions and the
|
||||
* store's selected item must not outlive the list row.
|
||||
*/
|
||||
/**
|
||||
* A lock change reached the results on screen, which were read under
|
||||
* the old lock state: close an open detail of a now-withheld genre (the
|
||||
* list hiding it is not enough) and drop the withheld rows NOW, before
|
||||
* the replacement page is awaited — on page 1 too, or they stay
|
||||
* clickable while (or, if it hangs, after) that request is pending.
|
||||
*/
|
||||
applyRelockToResults(
|
||||
withheldCategoryIds: ReadonlySet<string>,
|
||||
contentType: Parameters<typeof withoutWithheldStalkerItems>[1]
|
||||
): void {
|
||||
this.closeWithheldDetail(withheldCategoryIds);
|
||||
this.accumulatedSearchResults.set(
|
||||
withoutWithheldStalkerItems(
|
||||
this.accumulatedSearchResults(),
|
||||
contentType,
|
||||
withheldCategoryIds
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
closeWithheldDetail(withheldCategoryIds: ReadonlySet<string>): void {
|
||||
const details = this.itemDetails();
|
||||
if (!details) {
|
||||
return;
|
||||
}
|
||||
// A detail without a genre is withheld only in fail-closed mode
|
||||
// (`ALL_CATEGORIES_WITHHELD`): "unknown genre" is not "no locked
|
||||
// genre" while the locks themselves are unknown.
|
||||
const categoryId = details.category_id;
|
||||
const withheld =
|
||||
categoryId === undefined || categoryId === null || categoryId === ''
|
||||
? withheldCategoryIds === ALL_CATEGORIES_WITHHELD
|
||||
: withheldCategoryIds.has(String(categoryId));
|
||||
if (!withheld) {
|
||||
return;
|
||||
}
|
||||
const cleared = clearStalkerDetailViewState();
|
||||
this.itemDetails.set(cleared.itemDetails);
|
||||
this.vodDetailsItem.set(cleared.vodDetailsItem);
|
||||
this.isSelectedVodFavorite.set(false);
|
||||
this.selectedVodPosition.set(null);
|
||||
this.closeInlinePlayer();
|
||||
this.stalkerStore.setSelectedItem(null);
|
||||
}
|
||||
|
||||
/** Leave the search page (e.g. back to the actor page that opened it) */
|
||||
goBack(): void {
|
||||
this.location.back();
|
||||
|
||||
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user