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

* 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:
authored and GitHub committed 2026-09-27 16:38:34 +02:00
1 parent f0e51d2806
commit e45cd85a78
203 files changed
+15022 -432

No files matched your search

@@ -114,6 +114,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
import {
DataService,
ParentalLockService,
PlaylistsService,
RecordingsService,
RuntimeCapabilitiesService,
@@ -241,6 +242,7 @@ export class VideoPlayerComponent
private readonly router = inject(Router);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly parentalLock = inject(ParentalLockService);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly epgService = inject(EpgService);
@@ -1437,8 +1439,19 @@ export class VideoPlayerComponent
)
)
.subscribe((channel) => {
// The number indexes the full list; a channel of a locked
// group is not zapped to (the enforcement service would
// reset it anyway, after playback had started).
const playlistId = this.activePlaylistMeta()?._id;
if (
channel &&
!(
playlistId &&
this.parentalLock.isM3uGroupLocked(
playlistId,
channel.group?.title ?? ''
)
) &&
(!this.isLivePlayerFullscreen() ||
this.keepsInlinePlayer(channel))
) {
@@ -1,5 +1,23 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ParentalLockService } from '@iptvnator/services';
import { VodSourceDiscoveryService } from './vod-source-discovery.service';
const withholdsEverything = signal(false);
function createService(): VodSourceDiscoveryService {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{
provide: ParentalLockService,
useValue: { withholdsEverything },
},
],
});
return TestBed.inject(VodSourceDiscoveryService);
}
/**
* Discovery talks to a foreign playlist, and Xtream carries the account in the
* URL — so anything it fails on has to go through the redacting logger before
@@ -17,7 +35,7 @@ describe('VodSourceDiscoveryService — failure logging', () => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {
/* captured */
});
service = new VodSourceDiscoveryService();
service = createService();
});
afterEach(() => {
@@ -85,7 +103,7 @@ describe('VodSourceDiscoveryService — candidate mapping', () => {
row(4, undefined),
]),
};
const service = new VodSourceDiscoveryService();
const service = createService();
const result = await service.discover({
title: 'Dune',
@@ -111,3 +129,24 @@ describe('VodSourceDiscoveryService — candidate mapping', () => {
};
}
});
describe('VodSourceDiscoveryService — parental lock', () => {
afterEach(() => {
withholdsEverything.set(false);
delete (window as { electron?: unknown }).electron;
});
it('asks the worker nothing while the lock withholds everything', async () => {
const dbFindTitleSources = jest.fn().mockResolvedValue([]);
(window as { electron?: unknown }).electron = { dbFindTitleSources };
withholdsEverything.set(true);
const result = await createService().discover({
title: 'Dune',
currentPlaylistId: 'playlist-0',
});
expect(result.sources).toEqual([]);
expect(dbFindTitleSources).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { ParentalLockService } from '@iptvnator/services';
import {
unambiguousCategoryLanguage,
type VodSourceCandidate,
@@ -43,6 +44,7 @@ export interface VodSourceDiscoveryResult {
@Injectable({ providedIn: 'root' })
export class VodSourceDiscoveryService {
private readonly logger = createLogger('VodSourceDiscovery');
private readonly parentalLock = inject(ParentalLockService);
get isAvailable(): boolean {
return (
@@ -59,7 +61,14 @@ export class VodSourceDiscoveryService {
matchKind: 'title-year',
};
if (!this.isAvailable || !request.title?.trim()) {
// The worker withholds locked rows through its own lock state and
// index; while the lock withholds everything (store unreadable, or
// a bridge without the worker filter) neither can be trusted.
if (
!this.isAvailable ||
!request.title?.trim() ||
this.parentalLock.withholdsEverything()
) {
return empty;
}
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { DataService, ParentalLockService } from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
PlaylistMeta,
@@ -138,16 +138,27 @@ describe('withStalkerContent failure states', () => {
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let parentalLock: {
active: jest.Mock<boolean, []>;
version: ReturnType<typeof signal<number>>;
lockedStalkerIds: jest.Mock<string[], [string, string]>;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
parentalLock = {
active: jest.fn(() => false),
version: signal(0),
lockedStalkerIds: jest.fn(() => []),
};
TestBed.configureTestingModule({
providers: [
TestContentStore,
{ provide: DataService, useValue: dataService },
{ provide: ParentalLockService, useValue: parentalLock },
{
provide: StalkerItvCacheService,
useValue: createItvCacheMock(),
@@ -590,6 +601,149 @@ describe('withStalkerContent failure states', () => {
expect(store.totalCount()).toBe(2);
});
it('pages past a VOD page made only of parental-locked rows', async () => {
parentalLock.active.mockReturnValue(true);
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
dataService.sendIpcEvent.mockImplementation(
(_event: unknown, payload: { params?: { p?: number } }) => {
const page = Number(payload.params?.p ?? 1);
// Page 1: one visible film. Page 2: locked rows only. Page 3:
// the visible film paging must still reach.
const data =
page === 1
? [{ id: 'movie-1', name: 'One', category_id: '5' }]
: page === 2
? [
{ id: 'adult-1', name: 'A', category_id: '9' },
{ id: 'adult-2', name: 'B', category_id: '9' },
]
: [
{
id: 'movie-3',
name: 'Three',
category_id: '5',
},
];
return Promise.resolve({ js: { data, total_items: 4 } });
}
);
store.setSelectedContentType('vod');
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
{ category_id: '9', category_name: 'Adult' },
]);
store.setSelectedCategory('*');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 1);
expect(store.hasMoreContent()).toBe(true);
// The append lands on the fully withheld page; the loader must ask
// for the next one by itself instead of ending the list.
store.setPage(1);
await waitForCondition(
() => store.getPaginatedContent().length === 2,
60
);
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
'movie-1',
'movie-3',
]);
// Both withheld ids are subtracted from the portal's total.
expect(store.totalCount()).toBe(2);
expect(store.hasMoreContent()).toBe(false);
});
it('drops accumulated rows of a newly locked genre and restarts from page 1', async () => {
dataService.sendIpcEvent.mockImplementation(
(_event: unknown, payload: { params?: { p?: number } }) => {
const page = Number(payload.params?.p ?? 1);
const data =
page === 1
? [
{ id: 'movie-1', name: 'One', category_id: '5' },
{ id: 'adult-1', name: 'A', category_id: '9' },
]
: [{ id: 'movie-2', name: 'Two', category_id: '5' }];
return Promise.resolve({ js: { data, total_items: 3 } });
}
);
store.setSelectedContentType('vod');
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
{ category_id: '9', category_name: 'Adult' },
]);
store.setSelectedCategory('*');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 2);
store.setPage(1);
await waitForCondition(() => store.getPaginatedContent().length === 3);
// Lock now: the genre-9 row loaded on page 1 must leave the screen
// and the list must be rebuilt from page 1 under the new lock state.
parentalLock.active.mockReturnValue(true);
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
parentalLock.version.set(1);
await waitForCondition(
() =>
store.page() === 0 &&
!store.isPaginatedContentLoading() &&
store
.getPaginatedContent()
.every((item) => item.category_id !== '9'),
60
);
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
'movie-1',
]);
expect(store.totalCount()).toBe(2);
});
it('takes page-one rows of a newly locked genre off screen before the reload answers', async () => {
let hangReload = false;
dataService.sendIpcEvent.mockImplementation(() =>
hangReload
? new Promise(() => undefined)
: Promise.resolve({
js: {
data: [
{ id: 'movie-1', name: 'One', category_id: '5' },
{ id: 'adult-1', name: 'A', category_id: '9' },
],
total_items: 2,
},
})
);
store.setSelectedContentType('vod');
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
{ category_id: '9', category_name: 'Adult' },
]);
store.setSelectedCategory('*');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 2);
// Lock now while the replacement request hangs.
hangReload = true;
parentalLock.active.mockReturnValue(true);
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
parentalLock.version.set(1);
// Page 1 blanks the grid synchronously before its request, so the
// locked row is gone even though the reload never answers.
await waitForCondition(() => store.getPaginatedContent().length === 0);
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(2);
});
it('keeps accumulated pages when an append fails and retries the same page', async () => {
let failPageTwo = true;
dataService.sendIpcEvent.mockImplementation(
@@ -9,7 +9,13 @@ import {
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService, resetHostConnectivityGuard } from '@iptvnator/services';
import {
DataService,
ParentalLockService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
import {
StalkerCategoryItem,
StalkerContentItem,
@@ -31,8 +37,29 @@ import {
filterItvChannelsByGenre,
toStalkerContentItem,
toStalkerItvChannel,
stalkerWithheldRowKey,
withoutWithheldStalkerItems,
} from '../utils';
/**
* Genre ids of the given section that the parental lock currently withholds
* for this portal; empty while unlocked or off.
*/
function withheldStalkerCategoryIds(
parentalLock: ParentalLockService,
playlist: PlaylistMeta | undefined,
contentType: StalkerContentType
): ReadonlySet<string> {
const playlistId = playlist?._id;
if (!playlistId || !parentalLock.active()) {
return new Set();
}
if (parentalLock.withholdsEverything?.()) {
return ALL_CATEGORIES_WITHHELD;
}
return new Set(parentalLock.lockedStalkerIds(playlistId, contentType));
}
/**
* Content/categories/channels feature state.
*/
@@ -250,7 +277,8 @@ export function withStalkerContent() {
stalkerSession = inject(StalkerSessionService),
portalRepair = inject(StalkerPortalRepairService),
translateService = inject(TranslateService),
itvCache = inject(StalkerItvCacheService)
itvCache = inject(StalkerItvCacheService),
parentalLock = inject(ParentalLockService)
) => {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
@@ -261,6 +289,13 @@ export function withStalkerContent() {
};
let lastLivePageKey = '';
// Withheld (parental-locked) row ids seen while accumulating
// the current list. A page that adds only withheld ids still
// counts as progress, so paging continues past it; a page
// adding nothing new — withheld or not — is a stalled portal.
let withheldSeenKey = '';
const withheldSeenIds = new Set<string>();
let lastParentalLockVersion: number | null = null;
return {
categoryResource: resource({
params: () => ({
@@ -412,6 +447,10 @@ export function withStalkerContent() {
(category) =>
String(category.category_id) !== '*'
).length,
// Lock/unlock re-fires the loader: rows of a
// locked genre are dropped at patch time, so the
// list must be rebuilt when they become visible.
parentalLockVersion: parentalLock.version(),
}),
loader: async ({
params,
@@ -457,15 +496,74 @@ export function withStalkerContent() {
}
const categoryParam = params.category || '*';
const withheldCategoryIds =
withheldStalkerCategoryIds(
parentalLock,
playlist,
params.contentType
);
// A lock flip while the list is past page 1 must
// not append filtered rows onto pages that were
// accumulated under the old lock state: drop the
// withheld rows on screen now and restart from
// page 1 so the list is rebuilt under the new one.
if (
lastParentalLockVersion !== null &&
params.parentalLockVersion !==
lastParentalLockVersion &&
params.pageIndex > 1
) {
lastParentalLockVersion =
params.parentalLockVersion;
const retained = withoutWithheldStalkerItems(
store.paginatedContent(),
params.contentType,
withheldCategoryIds
);
patchState(store, {
paginatedContent: retained,
...(params.contentType === 'itv'
? {
itvChannels:
withoutWithheldStalkerItems(
store.itvChannels(),
'itv',
withheldCategoryIds
),
}
: params.contentType === 'radio'
? {
radioChannels:
withoutWithheldStalkerItems(
store.radioChannels(),
'radio',
withheldCategoryIds
),
}
: {}),
});
(
store as unknown as {
setPage?: (page: number) => void;
}
).setPage?.(0);
return retained;
}
lastParentalLockVersion =
params.parentalLockVersion;
if (params.contentType === 'itv') {
const cachedChannels =
itvCache.getChannels(playlist);
const channels =
cachedChannels !== null
? filterItvChannelsByGenre(
cachedChannels,
categoryParam
? withoutWithheldStalkerItems(
filterItvChannelsByGenre(
cachedChannels,
categoryParam
),
params.contentType,
withheldCategoryIds
)
: null;
// Serve from the cache only when it actually
@@ -522,6 +620,8 @@ export function withStalkerContent() {
params.pageIndex ===
storeContext.page() + 1 &&
paramsPlaylistKey === currentPlaylistKey &&
params.parentalLockVersion ===
parentalLock.version() &&
// A legacy paged response must not overwrite
// the full cached list that a re-fired
// loader served in the meantime. Scoped
@@ -609,12 +709,70 @@ export function withStalkerContent() {
return [];
}
const newItems = response.js.data.map((item) =>
const rawItems = response.js.data.map((item) =>
toStalkerContentItem(
item,
playlist.portalUrl ?? ''
)
);
const newItems = withoutWithheldStalkerItems(
rawItems,
params.contentType,
withheldCategoryIds
);
const listKey = JSON.stringify([
paramsPlaylistKey,
params.contentType,
params.category,
params.search,
]);
if (
params.pageIndex === 1 ||
withheldSeenKey !== listKey
) {
withheldSeenKey = listKey;
withheldSeenIds.clear();
}
let newWithheldCount = 0;
if (newItems.length < rawItems.length) {
const kept = new Set(newItems);
for (const item of rawItems) {
if (kept.has(item)) {
continue;
}
const id = stalkerWithheldRowKey(item);
if (!withheldSeenIds.has(id)) {
withheldSeenIds.add(id);
newWithheldCount += 1;
}
}
}
// A page made only of withheld rows would
// leave the list unchanged; request the next
// one so unlocked rows further on still load.
const skipWithheldPage = (hasMore: boolean) => {
if (
!hasMore ||
newItems.length > 0 ||
newWithheldCount === 0
) {
return;
}
queueMicrotask(() => {
if (isCurrentRequest()) {
// `page` belongs to the selection
// feature; the facade composes
// its setter ahead of this one.
(
store as unknown as {
setPage?: (
page: number
) => void;
}
).setPage?.(params.pageIndex);
}
});
};
if (
params.contentType === 'itv' ||
@@ -645,6 +803,16 @@ export function withStalkerContent() {
const replay =
livePageKey === lastLivePageKey;
lastLivePageKey = livePageKey;
const hasMoreChannels =
rawItems.length > 0 &&
(params.pageIndex === 1 ||
replay ||
newWithheldCount > 0 ||
nextChannels.length >
existingChannels.length) &&
nextChannels.length +
withheldSeenIds.size <
(response.js.total_items ?? 0);
patchState(store, {
totalCount:
response.js.total_items ?? 0,
@@ -662,15 +830,9 @@ export function withStalkerContent() {
},
}
: { radioChannels: nextChannels }),
hasMoreChannels:
channels.length > 0 &&
(params.pageIndex === 1 ||
replay ||
nextChannels.length >
existingChannels.length) &&
nextChannels.length <
(response.js.total_items ?? 0),
hasMoreChannels,
});
skipWithheldPage(hasMoreChannels);
} else {
// VOD/series pages accumulate into one
// continuous list for the infinite-scroll
@@ -694,18 +856,30 @@ export function withStalkerContent() {
// end on every scroll crossing.
const appendStalled =
params.pageIndex > 1 &&
newWithheldCount === 0 &&
nextContent.length <=
previousContent.length;
// Withheld rows count against the portal's
// total, or the grid would keep asking for
// pages the lock will never let it show.
const totalCount = appendStalled
? nextContent.length
: Math.max(
0,
(response.js.total_items ?? 0) -
withheldSeenIds.size
);
patchState(store, {
totalCount: appendStalled
? nextContent.length
: (response.js.total_items ?? 0),
totalCount,
paginatedContent: nextContent,
contentError: null,
appendError: null,
hasMoreChannels: false,
});
skipWithheldPage(
nextContent.length < totalCount
);
return nextContent;
}
@@ -744,16 +918,40 @@ export function withStalkerContent() {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
const itvCache = inject(StalkerItvCacheService);
const parentalLock = inject(ParentalLockService);
/**
* The whole portal's ITV channel list (all categories) when
* cached. `versionFor` establishes the reactive dependency so this
* cached, minus the genres the parental lock withholds.
* `versionFor` establishes the reactive dependency so this
* recomputes when the list becomes ready or is refreshed.
*/
const itvFullChannelList = computed(() => {
const playlist = storeContext.currentPlaylist();
itvCache.versionFor(playlist);
return itvCache.getChannels(playlist) ?? [];
parentalLock.version();
return withoutWithheldStalkerItems(
itvCache.getChannels(playlist) ?? [],
'itv',
withheldStalkerCategoryIds(parentalLock, playlist, 'itv')
);
});
/** The selected section's genres with the withheld ones removed. */
const visibleCategoryResource = computed(() => {
parentalLock.version();
const contentType = storeContext.selectedContentType();
const withheld = withheldStalkerCategoryIds(
parentalLock,
storeContext.currentPlaylist(),
contentType
);
const categories = getCategoriesByType(store, contentType);
return withheld.size === 0
? categories
: categories.filter(
(category) =>
!withheld.has(String(category.category_id))
);
});
/**
@@ -909,7 +1107,9 @@ export function withStalkerContent() {
storeContext.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() => store.contentError()),
getCategoryResource: computed(() =>
getCategoryResource: visibleCategoryResource,
/** Every genre of the section, locked ones included (lock dialog). */
getAllCategoriesForSelectedType: computed(() =>
getCategoriesByType(
store,
storeContext.selectedContentType()
@@ -4,4 +4,5 @@ export * from './stalker-playback-command.utils';
export * from './stalker-player-request.utils';
export * from './stalker-request.utils';
export * from './stalker-content-mappers';
export * from './stalker-parental-lock.utils';
export * from './stalker-sorting';
@@ -0,0 +1,79 @@
import {
isStalkerItemWithheld,
stalkerWithheldRowKey,
withoutWithheldStalkerItems,
} from './stalker-parental-lock.utils';
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
describe('stalker-parental-lock.utils', () => {
const withheld = new Set(['7', '9']);
it('reads the genre from tv_genre_id for live and radio rows', () => {
expect(isStalkerItemWithheld({ tv_genre_id: 7 }, 'itv', withheld)).toBe(
true
);
expect(
isStalkerItemWithheld({ tv_genre_id: '9' }, 'radio', withheld)
).toBe(true);
expect(isStalkerItemWithheld({ tv_genre_id: 8 }, 'itv', withheld)).toBe(
false
);
// A VOD row with a matching category_id is not a live row.
expect(
isStalkerItemWithheld({ category_id: '7' }, 'itv', withheld)
).toBe(false);
});
it('reads category_id for VOD and series rows', () => {
expect(
isStalkerItemWithheld({ category_id: '7' }, 'vod', withheld)
).toBe(true);
expect(
isStalkerItemWithheld({ category_id: 9 }, 'series', withheld)
).toBe(true);
expect(isStalkerItemWithheld({}, 'vod', withheld)).toBe(false);
});
it('returns the same array when nothing is withheld', () => {
const items = [{ category_id: '7' }];
expect(withoutWithheldStalkerItems(items, 'vod', new Set())).toBe(
items
);
expect(withoutWithheldStalkerItems(items, 'vod', withheld)).toEqual([]);
});
});
describe('isStalkerItemWithheld in fail-closed mode', () => {
it('withholds rows without a genre only while everything is withheld', () => {
expect(isStalkerItemWithheld({}, 'itv', ALL_CATEGORIES_WITHHELD)).toBe(
true
);
expect(
isStalkerItemWithheld(
{ name: 'x' } as never,
'vod',
ALL_CATEGORIES_WITHHELD
)
).toBe(true);
expect(isStalkerItemWithheld({}, 'itv', new Set(['9']))).toBe(false);
expect(
withoutWithheldStalkerItems(
[{ tv_genre_id: '1' }, {}],
'itv',
ALL_CATEGORIES_WITHHELD
)
).toEqual([]);
});
});
describe('stalkerWithheldRowKey', () => {
it('keys rows by whichever id field they carry, never by a shared empty string', () => {
expect(stalkerWithheldRowKey({ id: 5 })).toBe('id:5');
expect(stalkerWithheldRowKey({ stream_id: '6' })).toBe('id:6');
expect(stalkerWithheldRowKey({ movie_id: 7 })).toBe('id:7');
expect(stalkerWithheldRowKey({ series_id: 8 })).toBe('id:8');
expect(stalkerWithheldRowKey({ cmd: 'ffmpeg http://a' })).not.toBe(
stalkerWithheldRowKey({ cmd: 'ffmpeg http://b' })
);
});
});
@@ -0,0 +1,69 @@
import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces';
import type { StalkerContentType } from '../stalker-store.contracts';
/** Minimal item shape the parental lock needs: the category a row belongs to. */
export interface StalkerLockableItem {
tv_genre_id?: string | number;
category_id?: string | number;
id?: string | number;
stream_id?: string | number;
movie_id?: string | number;
series_id?: string | number;
cmd?: string;
name?: string;
}
/**
* The key the paging bookkeeping records a withheld row under. Stalker rows
* carry their id in one of several fields; keying every id-less row under
* `''` would count only the first of them as new, and a page of new locked
* rows would then look like a stalled portal and end paging short of later
* visible matches.
*/
export function stalkerWithheldRowKey(item: StalkerLockableItem): string {
const id = item.id ?? item.stream_id ?? item.movie_id ?? item.series_id;
if (id !== undefined && id !== null && String(id) !== '') {
return `id:${String(id)}`;
}
return `row:${String(item.cmd ?? item.name ?? '')}`;
}
/**
* Whether an item belongs to one of the withheld categories. Live and radio
* rows carry their genre in `tv_genre_id`; VOD and series rows carry
* `category_id`. Only matters for portal-wide lists ("All" and search),
* since a withheld category can never be selected on its own.
*/
export function isStalkerItemWithheld(
item: StalkerLockableItem,
contentType: StalkerContentType,
withheldCategoryIds: ReadonlySet<string>
): boolean {
if (withheldCategoryIds.size === 0) {
return false;
}
const categoryId =
contentType === 'itv' || contentType === 'radio'
? item.tv_genre_id
: item.category_id;
if (categoryId === undefined || categoryId === null) {
// A row without a genre is visible under a normal lock set but not
// in fail-closed mode: while the locks are unknown, "no genre" must
// not become the one row the withheld catalog still shows.
return withheldCategoryIds === ALL_CATEGORIES_WITHHELD;
}
return withheldCategoryIds.has(String(categoryId));
}
export function withoutWithheldStalkerItems<T extends StalkerLockableItem>(
items: T[],
contentType: StalkerContentType,
withheldCategoryIds: ReadonlySet<string>
): T[] {
if (withheldCategoryIds.size === 0) {
return items;
}
return items.filter(
(item) => !isStalkerItemWithheld(item, contentType, withheldCategoryIds)
);
}
+1
View File
@@ -4,6 +4,7 @@ export * from './lib/stalker-collection-detail.component';
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
export * from './lib/stalker-catalog-facade.service';
export * from './lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component';
export * from './lib/stalker-feature.routes';
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
export * from './lib/stalker-search/stalker-search.component';
@@ -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,
]);
};
}
@@ -0,0 +1,105 @@
<h2 mat-dialog-title>{{ 'PARENTAL_LOCK.MANAGE_LOCKS' | translate }}</h2>
<mat-dialog-content>
<p class="lock-dialog__description">
{{ 'PARENTAL_LOCK.MANAGE_LOCKS_DESCRIPTION' | translate }}
</p>
<div class="header-actions">
<div class="selection-info">
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong> / {{ categories().length }}
</div>
<div class="bulk-actions">
@if (censoredCount() > 0) {
<button
mat-stroked-button
type="button"
data-test-id="stalker-lock-censored"
[disabled]="isSaving()"
(click)="lockCensored()"
>
{{ 'PARENTAL_LOCK.LOCK_CENSORED' | translate }}
</button>
}
<button
mat-stroked-button
type="button"
[disabled]="isSaving() || lockedCount() === 0"
(click)="unlockAll()"
>
{{ 'PARENTAL_LOCK.UNLOCK_ALL' | translate }}
</button>
</div>
</div>
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER' | translate"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
<div class="categories-list">
@for (category of filteredCategories(); track category.id) {
<button
type="button"
class="category-item"
[class.category-item--locked]="category.locked"
[attr.aria-pressed]="category.locked"
[attr.data-test-id]="'stalker-category-lock-' + category.id"
(click)="toggle(category)"
>
<mat-icon class="category-item__icon">{{
category.locked ? 'lock' : 'lock_open'
}}</mat-icon>
<span class="category-name">{{ category.name }}</span>
@if (category.censored) {
<span
class="censored-chip"
[matTooltip]="'PARENTAL_LOCK.CENSORED_HINT' | translate"
>18+</span
>
}
</button>
} @empty {
<div class="empty-message">
{{ 'XTREAM.CATEGORY_MANAGEMENT.NO_RESULTS' | translate }}
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()" [disabled]="isSaving()">
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
color="primary"
type="button"
data-test-id="stalker-category-lock-save"
(click)="save()"
[disabled]="isSaving()"
>
@if (isSaving()) {
<mat-spinner diameter="20" />
} @else {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SAVE' | translate }}
}
</button>
</mat-dialog-actions>
@@ -0,0 +1,161 @@
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.lock-dialog__description {
margin: 0;
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
}
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.selection-info {
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
strong {
color: var(--mat-sys-primary);
}
}
.bulk-actions {
display: flex;
gap: 8px;
button {
font-size: 12px;
}
}
.search-inline {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
min-height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
cursor: text;
&:focus-within {
border-color: var(--mat-sys-primary);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.84rem;
min-width: 0;
&::-webkit-search-cancel-button {
display: none;
}
}
&__clear {
width: 24px;
height: 24px;
min-width: 24px;
padding: 0;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.categories-list {
flex: 1;
min-height: 200px;
max-height: 50vh;
overflow-y: auto;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
}
.category-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 0 10px;
min-height: 32px;
border: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover {
background-color: var(--mat-sys-surface-container);
}
&:not(:last-child) {
border-bottom: 1px solid var(--mat-sys-surface-container);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
&--locked .category-item__icon {
color: var(--mat-sys-primary);
}
}
.category-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.censored-chip {
font-size: 0.6875rem;
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--mat-sys-error) 16%, transparent);
color: var(--mat-sys-error);
}
.empty-message {
padding: 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8125rem;
}
:host ::ng-deep .mat-mdc-dialog-content {
min-width: 440px;
max-width: 600px;
}
@@ -0,0 +1,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();
});
});
@@ -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',
@@ -20,10 +20,7 @@ describe('StalkerItvAllItemsComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
StalkerItvAllItemsComponent,
TranslateModule.forRoot(),
],
imports: [StalkerItvAllItemsComponent, TranslateModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(StalkerItvAllItemsComponent);
@@ -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);
});
});
@@ -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;
}
@@ -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([])
@@ -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
@@ -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
);
}
@@ -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();
@@ -1,3 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { ParentalLockService } from '@iptvnator/services';
import {
credentials,
ElectronXtreamDataSourceHarness,
@@ -21,6 +23,7 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
type: 'live' as const,
xtream_id: 10,
hidden: false,
locked: false,
};
const dbContentItem = {
id: 1,
@@ -33,6 +36,48 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
harness = setupElectronXtreamDataSource();
});
describe('withholding everything', () => {
it('serves no categories, content or search hits while the lock store withholds everything', async () => {
const parentalLock = TestBed.inject(ParentalLockService);
Object.defineProperty(parentalLock, 'withholdsEverything', {
configurable: true,
value: () => true,
});
harness.dbService.getXtreamImportStatus.mockResolvedValue(
'completed'
);
harness.dbService.getXtreamCategories.mockResolvedValue([
dbCategory,
]);
harness.dbService.getXtreamContent.mockResolvedValue([
dbContentItem,
]);
await expect(
harness.dataSource.getCategories(
playlistId,
credentials,
'live'
)
).resolves.toEqual([]);
await expect(
harness.dataSource.getContent(playlistId, credentials, 'live')
).resolves.toEqual([]);
await expect(
harness.dataSource.searchContent(playlistId, 'news', ['live'])
).resolves.toEqual([]);
await expect(
harness.dataSource.getCachedCategories(playlistId, 'live')
).resolves.toEqual([]);
await expect(
harness.dataSource.getCachedContent(playlistId, 'live')
).resolves.toEqual([]);
expect(
harness.dbService.getXtreamCategories
).not.toHaveBeenCalled();
});
});
describe('getCategories', () => {
it('returns cached categories without calling the API when import is completed', async () => {
harness.dbService.getXtreamImportStatus.mockResolvedValue(
@@ -56,6 +101,26 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
);
});
it('keeps a complete cache whose every category the parental lock withholds', async () => {
// The filtered read returns nothing, but rows exist: refetching
// from the provider would be wasted work and, for content, a
// full re-import.
harness.dbService.getXtreamImportStatus.mockResolvedValue(
'completed'
);
harness.dbService.getXtreamCategories.mockResolvedValue([]);
harness.dbService.hasXtreamCategories.mockResolvedValue(true);
const result = await harness.dataSource.getCategories(
playlistId,
credentials,
'live'
);
expect(result).toEqual([]);
expect(harness.apiService.getCategories).not.toHaveBeenCalled();
});
it('fetches from the API and caches to DB when the cache is cold', async () => {
const remoteCategories = [
{ category_id: '10', category_name: 'News' },
@@ -84,7 +149,9 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
playlistId,
remoteCategories,
'movies',
undefined
undefined,
// Parental lock: no locks stored for the playlist.
[]
);
expect(result).toEqual([dbCategory]);
expect(onPhaseChange.mock.calls).toEqual([
@@ -174,7 +241,8 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
playlistId,
expect.any(Array),
'live',
[5]
[5],
[]
);
});
@@ -207,7 +275,11 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
.mockResolvedValueOnce([]);
await expect(
harness.dataSource.getCategories(playlistId, credentials, 'live')
harness.dataSource.getCategories(
playlistId,
credentials,
'live'
)
).rejects.toThrow('portal unreachable');
await harness.dataSource.getCategories(
@@ -1,6 +1,7 @@
import { inject, Injectable } from '@angular/core';
import {
DatabaseService,
ParentalLockService,
PlaybackPositionService,
XtreamPendingRestoreService,
XtreamImportStatus,
@@ -46,6 +47,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
XtreamPendingRestoreService
);
private readonly apiService = inject(XtreamApiService);
private readonly parentalLock = inject(ParentalLockService);
private readonly categoryRequests = new Map<
string,
Promise<XtreamCategoryFromDb[]>
@@ -147,8 +149,17 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategoryFromDb[]> {
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
const dbType = mapCategoryTypeToDbType(type);
const requestKey = `${playlistId}:${dbType}`;
// The lock version keys the share: a read issued under an older lock
// state (still unlocked, or locks since edited) answers with rows the
// caller under the newer state must not be handed.
const requestKey = `${playlistId}:${dbType}:${this.parentalLock.version()}`;
const inFlightRequest = this.categoryRequests.get(requestKey);
if (inFlightRequest) {
@@ -187,7 +198,14 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId,
dbType
);
if (importStatus === 'completed' && cached.length > 0) {
// The read is filtered by the parental lock, so an empty result is
// not proof of a cold cache: a type whose every category is locked
// is complete and must not be refetched from the provider.
if (
importStatus === 'completed' &&
(cached.length > 0 ||
(await this.dbService.hasXtreamCategories(playlistId, dbType)))
) {
return cached;
}
@@ -213,7 +231,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId,
remoteData,
dbType,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
// Locks live in the renderer's store; the fresh rows get the
// SQLite mirror stamped on insert so a refresh keeps them.
this.parentalLock.lockedXtreamIds(playlistId, dbType)
);
}
@@ -252,6 +273,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId: string,
type: CategoryType
): Promise<XtreamCategoryFromDb[]> {
// Same fail-closed gate as the live reads: the warm-route hydration
// reads the cache directly and must not publish rows a stale index
// stamp still marks unlocked.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
return this.dbService.getXtreamCategories(
playlistId,
mapCategoryTypeToDbType(type)
@@ -292,7 +319,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamContentItem[]> {
const requestKey = `${playlistId}:${type}`;
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
const requestKey = `${playlistId}:${type}:${this.parentalLock.version()}`;
const inFlightRequest = this.contentRequests.get(requestKey);
if (inFlightRequest) {
@@ -329,7 +362,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
const cached = await this.dbService.getXtreamContent(playlistId, type);
if (importStatus === 'completed' && cached.length > 0) {
// Filtered by the parental lock like the category read: empty is not
// cold while unfiltered rows exist.
if (
importStatus === 'completed' &&
(cached.length > 0 ||
(await this.dbService.hasXtreamContent(playlistId, type)))
) {
return cached;
}
@@ -370,6 +409,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId: string,
type: StreamType
): Promise<XtreamContentItem[]> {
// Same fail-closed gate as the live reads: the warm-route hydration
// reads the cache directly and must not publish rows a stale index
// stamp still marks unlocked.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
return this.dbService.getXtreamContent(playlistId, type);
}
@@ -403,6 +448,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
types: string[],
excludeHidden?: boolean
): Promise<XtreamContentItem[]> {
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
return this.dbService.searchXtreamContent(
playlistId,
searchTerm,
@@ -5,7 +5,7 @@ import {
XtreamApiService,
XtreamCredentials,
} from '../services/xtream-api.service';
import { PlaylistsService } from '@iptvnator/services';
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
import { of } from 'rxjs';
describe('PwaXtreamDataSource', () => {
@@ -17,6 +17,11 @@ describe('PwaXtreamDataSource', () => {
getPlaylistById: jest.Mock;
transformPlaylistMeta: jest.Mock;
};
let parentalLock: {
active: jest.Mock<boolean, []>;
lockedXtreamIds: jest.Mock<number[], [string, string]>;
withholdsEverything: jest.Mock<boolean, []>;
};
const credentials: XtreamCredentials = {
serverUrl: 'http://localhost:3211',
@@ -34,10 +39,16 @@ describe('PwaXtreamDataSource', () => {
getPlaylistById: jest.fn(() => of(undefined)),
transformPlaylistMeta: jest.fn(() => of(null)),
};
parentalLock = {
active: jest.fn(() => false),
lockedXtreamIds: jest.fn(() => []),
withholdsEverything: jest.fn(() => false),
};
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{ provide: ParentalLockService, useValue: parentalLock },
{
provide: XtreamApiService,
useValue: apiService,
@@ -56,6 +67,77 @@ describe('PwaXtreamDataSource', () => {
localStorage.clear();
});
it('withholds locked categories from catalog reads and search while the lock is active', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'Family film', category_id: '5' },
{ stream_id: 2, name: 'Family after dark', category_id: '9' },
]);
parentalLock.active.mockReturnValue(true);
parentalLock.lockedXtreamIds.mockImplementation((_id, type) =>
type === 'movies' ? [9] : []
);
const content = await dataSource.getContent(
'playlist-1',
credentials,
'movie'
);
const found = await dataSource.searchContent('playlist-1', 'family', [
'movie',
]);
expect(content.map((item) => item.name)).toEqual(['Family film']);
expect(found.map((item) => item.name)).toEqual(['Family film']);
parentalLock.active.mockReturnValue(false);
const unlocked = await dataSource.searchContent(
'playlist-1',
'family',
['movie']
);
expect(unlocked).toHaveLength(2);
});
it('matches a noncanonical provider category id against its numeric lock', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'Family film', category_id: '5' },
{ stream_id: 2, name: 'Family after dark', category_id: '009' },
]);
parentalLock.active.mockReturnValue(true);
parentalLock.lockedXtreamIds.mockImplementation((_id, type) =>
type === 'movies' ? [9] : []
);
const content = await dataSource.getContent(
'playlist-1',
credentials,
'movie'
);
expect(content.map((item) => item.name)).toEqual(['Family film']);
});
it('withholds everything while the lock store cannot be read', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'Family film', category_id: '5' },
]);
parentalLock.active.mockReturnValue(true);
parentalLock.withholdsEverything = jest.fn(() => true);
const content = await dataSource.getContent(
'playlist-1',
credentials,
'movie'
);
const found = await dataSource.searchContent('playlist-1', 'family', [
'movie',
]);
expect(content).toEqual([]);
expect(found).toEqual([]);
expect(parentalLock.lockedXtreamIds).not.toHaveBeenCalled();
});
it('reports remote loading phases for API fetches but stays silent on cache hits', async () => {
apiService.getStreams.mockResolvedValue([
{ stream_id: 1, name: 'News' },
@@ -1,5 +1,6 @@
import { inject, Injectable, Injector } from '@angular/core';
import {
ALL_CATEGORIES_WITHHELD,
foldSearchText,
ContentMetadataPatch,
Playlist,
@@ -17,7 +18,7 @@ import {
XtreamApiService,
XtreamCredentials,
} from '../services/xtream-api.service';
import { PlaylistsService } from '@iptvnator/services';
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
import { firstValueFrom } from 'rxjs';
import {
DbCategoryType,
@@ -82,6 +83,7 @@ type StoredXtreamPlaylistData = Omit<XtreamPlaylistData, 'password'> & {
export class PwaXtreamDataSource implements IXtreamDataSource {
private readonly apiService = inject(XtreamApiService);
private readonly injector = inject(Injector);
private readonly parentalLock = inject(ParentalLockService);
private readonly logger = createLogger('PwaXtreamDataSource');
private readonly contentTypes = ['live', 'movie', 'series'] as const;
@@ -332,7 +334,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Check in-memory cache first
const cachedCategories = this.categoryCache.get(cacheKey);
if (cachedCategories) {
return cachedCategories;
return this.withoutLockedCategories(
playlistId,
type,
cachedCategories
);
}
// Fetch from API
@@ -345,17 +351,112 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Cache in memory
this.categoryCache.set(cacheKey, categories);
return categories;
return this.withoutLockedCategories(playlistId, type, categories);
}
/**
* Parental lock (PWA): there is no SQLite worker to filter reads, so the
* withheld categories are dropped here, at the same boundary the
* Electron source reads them through.
*/
private withheldCategoryIds(
playlistId: string,
type: CategoryType | StreamType
): ReadonlySet<string> {
if (!this.parentalLock.active()) {
return new Set();
}
if (this.parentalLock.withholdsEverything?.()) {
return ALL_CATEGORIES_WITHHELD;
}
const dbType =
type === 'vod' || type === 'movie'
? 'movies'
: type === 'series'
? 'series'
: 'live';
return new Set(
this.parentalLock
.lockedXtreamIds(playlistId, dbType)
.map((id) => String(id))
);
}
private withoutLockedCategories(
playlistId: string,
type: CategoryType,
categories: XtreamCategory[]
): XtreamCategory[] {
const withheld = this.withheldCategoryIds(playlistId, type);
return withheld.size === 0
? categories
: categories.filter(
(category) =>
!withheld.has(canonicalCategoryId(category.category_id))
);
}
private withoutLockedContent<T extends { category_id?: string | number }>(
playlistId: string,
type: StreamType,
items: T[]
): T[] {
const withheld = this.withheldCategoryIds(playlistId, type);
return withheld.size === 0
? items
: items.filter(
(item) => !withheld.has(canonicalCategoryId(item.category_id))
);
}
/**
* The raw category list in the SQLite wire shape, locked ones included:
* the lock editor's candidates. Hidden/shown is not tracked in the PWA,
* so every row reports `hidden: false`. Reads the session cache and
* falls back to the API with the stored credentials on a cold session.
*/
async getAllCategories(
playlistId: string,
type: DbCategoryType
): Promise<XtreamCategoryFromDb[]> {
void playlistId;
void type;
// PWA doesn't track hidden categories - return empty
return [];
const categoryType: CategoryType = type === 'movies' ? 'vod' : type;
const cacheKey = `${playlistId}-${categoryType}-categories`;
let categories = this.categoryCache.get(cacheKey);
if (!categories) {
const playlist = await this.getPlaylist(playlistId);
if (!playlist) {
return [];
}
categories = await this.apiService.getCategories(
{
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
categoryType
);
this.categoryCache.set(cacheKey, categories);
}
const locked = new Set(
this.parentalLock.lockedXtreamIds(playlistId, type)
);
const rows: XtreamCategoryFromDb[] = [];
for (const category of categories) {
const xtreamId = Number(category.category_id);
if (!Number.isFinite(xtreamId)) {
continue;
}
rows.push({
id: xtreamId,
name: category.category_name,
playlist_id: playlistId,
type,
xtream_id: xtreamId,
hidden: false,
locked: locked.has(xtreamId),
});
}
return rows;
}
async getCachedCategories(
@@ -417,8 +518,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Check in-memory cache first
const cachedContent = this.contentCache.get(cacheKey);
if (cachedContent) {
return cachedContent as
XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[];
return this.withoutLockedContent(
playlistId,
type,
cachedContent
) as XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[];
}
// Fetch from API
@@ -445,7 +549,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Cache in memory
this.contentCache.set(cacheKey, content);
return content;
return this.withoutLockedContent(playlistId, type, content);
}
async getCachedContent(
@@ -553,7 +657,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
for (const type of types) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
// Same lock boundary as the catalog read: a search must not
// surface rows the category list withholds.
const content = this.withoutLockedContent(
playlistId,
type as StreamType,
this.contentCache.get(cacheKey) || []
);
const filtered = content.filter((item) => {
const title =
@@ -1476,3 +1586,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
this.contentCache.clear();
}
}
/**
* The lock store keys provider category ids as numbers (the lock editor
* converts with `Number`), so a noncanonical provider id such as `"001"`
* must be compared as `"1"`. Non-numeric and empty ids stay as they are.
*/
function canonicalCategoryId(id: string | number | null | undefined): string {
const raw = String(id ?? '').trim();
const numeric = Number(raw);
return raw !== '' && Number.isFinite(numeric) ? String(numeric) : raw;
}
@@ -88,6 +88,8 @@ export interface XtreamCategoryFromDb {
type: 'movies' | 'live' | 'series';
xtream_id: number;
hidden: boolean;
/** Parental lock index; see ParentalLockService. */
locked: boolean;
}
/**
@@ -0,0 +1,277 @@
import { TestBed } from '@angular/core/testing';
import { PortalStatusType } from '../../xtream-state';
import {
createContentTestProviders,
createContentTestStore,
createDeferred,
createPendingRestoreServiceMock,
waitForCondition,
} from './with-content.feature.spec-helpers';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
type ContentType = 'live' | 'movie' | 'series';
const TestContentStore = createContentTestStore(() =>
Promise.resolve('active' as PortalStatusType)
);
/**
* Parental-lock reloads (`reloadCategories` / `reloadCachedContent`) fail
* closed. Split from `with-content.feature.spec.ts`, which sits at the spec
* line cap.
*/
describe('withContent parental-lock reloads', () => {
let store: InstanceType<typeof TestContentStore>;
let dataSource: {
getCategories: jest.Mock;
getCachedCategories: jest.Mock;
getContent: jest.Mock;
getCachedContent: jest.Mock;
hasCategories: jest.Mock;
hasContent: jest.Mock;
restoreUserData: jest.Mock;
};
beforeEach(() => {
localStorage.clear();
dataSource = {
getCategories: jest.fn().mockResolvedValue([]),
getCachedCategories: jest.fn().mockResolvedValue([]),
getContent: jest.fn().mockResolvedValue([]),
getCachedContent: jest.fn().mockResolvedValue([]),
hasCategories: jest.fn().mockResolvedValue(true),
hasContent: jest.fn().mockResolvedValue(false),
restoreUserData: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: createContentTestProviders(TestContentStore, {
dataSource,
databaseService: {
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest.fn(
(prefix?: string) => `${prefix ?? 'db-op'}-1`
),
getXtreamImportStatus: jest
.fn()
.mockResolvedValue('completed'),
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
supportsDbOperationCancellation: jest
.fn()
.mockReturnValue(true),
},
xtreamApiService: {
cancelSession: jest.fn().mockResolvedValue(true),
},
pendingRestoreService: createPendingRestoreServiceMock(),
dataService: {
sendIpcEvent: jest
.fn()
.mockResolvedValue({ success: true }),
},
}),
});
store = TestBed.inject(TestContentStore);
});
afterEach(() => localStorage.clear());
it('withholds every catalog list at once and drops reads whose guard says no', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
await store.initializeContent();
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
store.withholdCatalog();
expect(store.liveCategories()).toEqual([]);
expect(store.vodStreams()).toEqual([]);
expect(store.serialStreams()).toEqual([]);
expect(store.contentLoadStateByType().live).toBe('ready');
await store.reloadCategories(() => false);
await store.reloadCachedContent(() => false);
expect(store.liveCategories()).toEqual([]);
expect(store.liveStreams()).toEqual([]);
await store.reloadCachedContent();
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
});
it('withholds rows published by a hydration a relock overtook and reloads them afterwards', async () => {
const live = createDeferred<unknown[]>();
let calls = 0;
dataSource.getContent.mockImplementation(() => {
calls += 1;
if (calls === 1) {
// The hydration's live read is held open; the relock lands
// while it is in flight.
return live.promise;
}
// Reads 2-3 still belong to the hydration, later ones to the
// deferred filtered reload.
return Promise.resolve(
calls <= 3 ? [{ xtream_id: 1 }] : [{ xtream_id: 2 }]
);
});
let categoryCalls = 0;
dataSource.getCategories.mockImplementation(async () => {
categoryCalls += 1;
return categoryCalls <= 3
? [{ category_id: 'old' }]
: [{ category_id: 'new' }];
});
const initialization = store.initializeContent();
await waitForCondition(() => calls === 1);
await store.reloadCachedContent();
expect(calls).toBe(1);
live.resolve([{ xtream_id: 1 }]);
await initialization;
expect(calls).toBe(6);
expect(categoryCalls).toBe(6);
expect(store.liveCategories()).toEqual([{ category_id: 'new' }]);
expect(store.liveStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.vodStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.serialStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.isContentInitialized()).toBe(true);
});
it("carries the deferred request's publish guard into the reload", async () => {
const live = createDeferred<unknown[]>();
let calls = 0;
dataSource.getContent.mockImplementation(() => {
calls += 1;
return calls === 1
? live.promise
: Promise.resolve([{ xtream_id: calls }]);
});
const initialization = store.initializeContent();
await waitForCondition(() => calls === 1);
let current = true;
await store.reloadCachedContent(() => current);
// The lock moved on again before the hydration settled.
current = false;
live.resolve([{ xtream_id: 1 }]);
await initialization;
// The reload stops at its first guard check instead of publishing
// the older rows; the superseding apply reads everything again.
expect(calls).toBe(4);
expect(store.liveStreams()).toEqual([]);
expect(store.vodStreams()).toEqual([]);
expect(store.serialStreams()).toEqual([]);
});
it('suppresses a hydration publication as soon as the catalog is withheld', async () => {
const live = createDeferred<unknown[]>();
let calls = 0;
let liveAfterHydrationPublish: unknown;
dataSource.getContent.mockImplementation(() => {
calls += 1;
if (calls === 1) {
return live.promise;
}
if (calls === 2) {
// The hydration published its live rows just before this
// (vod) read: capture what it left on screen.
liveAfterHydrationPublish = store.liveStreams();
}
return Promise.resolve([{ xtream_id: calls }]);
});
const initialization = store.initializeContent();
await waitForCondition(() => calls === 1);
// Relock: the catalog is withheld BEFORE any reload has run, then
// the hydration's pending (unlocked) live read resolves.
store.withholdCatalog();
live.resolve([{ xtream_id: 1 }]);
await initialization;
expect(liveAfterHydrationPublish).toEqual([]);
// The deferred reload refilled the list with fresh rows.
expect(store.liveStreams()).not.toEqual([{ xtream_id: 1 }]);
expect(store.liveStreams()).not.toEqual([]);
});
it('empties the category lists when their reload fails', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
await store.reloadCategories();
expect(store.liveCategories()).toEqual([{ category_id: 'x' }]);
dataSource.getCategories.mockRejectedValue(new Error('db'));
await store.reloadCategories();
expect(store.liveCategories()).toEqual([]);
expect(store.vodCategories()).toEqual([]);
expect(store.serialCategories()).toEqual([]);
});
it('empties a type whose cached reload fails and lets the others reload', async () => {
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
await store.initializeContent();
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'ready',
series: 'ready',
});
dataSource.getContent.mockImplementation(
(_playlistId: string, _credentials: unknown, type: ContentType) =>
type === 'movie'
? Promise.reject(new Error('db'))
: Promise.resolve([{ xtream_id: 2 }])
);
await store.reloadCachedContent();
expect(store.vodStreams()).toEqual([]);
expect(store.liveStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.serialStreams()).toEqual([{ xtream_id: 2 }]);
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'idle',
series: 'ready',
});
});
it('drops reloads whose playlist was switched away while they read', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'a' }]);
dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]);
await store.initializeContent();
const categories = createDeferred<unknown[]>();
const content = createDeferred<unknown[]>();
dataSource.getCategories.mockReturnValue(categories.promise);
dataSource.getContent.mockImplementation(
(_playlistId: string, _credentials: unknown, type: ContentType) =>
type === 'live'
? content.promise
: Promise.reject(new Error('db'))
);
const reloadingCategories = store.reloadCategories();
const reloadingContent = store.reloadCachedContent();
store.switchPlaylist('playlist-b');
categories.resolve([{ category_id: 'stale' }]);
content.resolve([{ xtream_id: 99 }]);
await reloadingCategories;
await reloadingContent;
expect(store.liveCategories()).toEqual([{ category_id: 'a' }]);
expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]);
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'ready',
series: 'ready',
});
});
});
@@ -114,6 +114,10 @@ export function createContentTestStore(
patchState(store, { portalStatus: status });
return status;
},
/** Simulates the user opening another playlist. */
switchPlaylist(playlistId: string): void {
patchState(store, { playlistId });
},
})),
withContent()
);
@@ -219,6 +219,28 @@ export function withContent() {
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
/**
* A filtered reload was requested (parental lock changed) while
* the initial hydration was still publishing rows it read under
* the previous lock state: those publications are withheld and
* the reload runs once the hydration has settled.
*/
let reloadAfterInitialization = false;
/** The publish guard of the LATEST deferred request (see below). */
let deferredPublishGuard: () => boolean = () => true;
const runDeferredReload = async (): Promise<void> => {
if (!reloadAfterInitialization) {
return;
}
reloadAfterInitialization = false;
// The requester's guard travels with the deferred reload: a
// relock during these reads supersedes them exactly as it
// would an ordinary reload.
const shouldPublish = deferredPublishGuard;
deferredPublishGuard = () => true;
await methods.reloadCategories(shouldPublish);
await methods.reloadCachedContent(shouldPublish);
};
const dataService = inject(DataService);
const databaseService = inject(DatabaseService);
const pendingRestoreService = inject(XtreamPendingRestoreService);
@@ -237,6 +259,15 @@ export function withContent() {
Promise<void>
>();
// A reload publishes only while its playlist is still the open
// one: the store is shared, and rows read for playlist A must
// not land in it after the user switched to playlist B.
const publishWhileCurrent =
(playlistId: string, shouldPublish: () => boolean) =>
(): boolean =>
getPortalStore().playlistId?.() === playlistId &&
shouldPublish();
const getCachedHydrationKey = (
playlistId: string,
scope?: XtreamCachedContentScope | null
@@ -630,8 +661,25 @@ export function withContent() {
}
updates.contentLoadStateByType = nextLoadStates;
if (reloadAfterInitialization) {
// Read before the lock changed; the deferred reload
// below publishes the filtered rows instead.
for (const key of [
'liveCategories',
'vodCategories',
'serialCategories',
'liveStreams',
'vodStreams',
'serialStreams',
] as const) {
if (key in updates) {
updates[key] = [];
}
}
}
return updates;
});
await runDeferredReload();
};
const hydrateCachedContentForScope = async (
@@ -652,6 +700,7 @@ export function withContent() {
isPendingRestoreBlocked: false,
contentInitBlockReason: null,
});
await runDeferredReload();
return;
}
@@ -999,6 +1048,9 @@ export function withContent() {
isContentInitialized: true,
contentInitBlockReason: null,
});
// A lock change during the hydration withheld the rows
// it published; read them again under the current lock.
await runDeferredReload();
} catch (error) {
if (store.isImporting()) {
await finalizePendingImportTypes(
@@ -1136,10 +1188,16 @@ export function withContent() {
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_CATEGORIES,
() =>
patchState(store, {
liveCategories: live,
vodCategories: vod,
liveCategories: reloadAfterInitialization
? []
: live,
vodCategories: reloadAfterInitialization
? []
: vod,
vodCategoriesPlaylistId: ctx.playlistId,
serialCategories: series,
serialCategories: reloadAfterInitialization
? []
: series,
isLoadingCategories: false,
}),
() => ({
@@ -1222,7 +1280,9 @@ export function withContent() {
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_LIVE,
() =>
patchState(store, {
liveStreams: live,
liveStreams: reloadAfterInitialization
? []
: live,
}),
() => ({ items: live.length })
);
@@ -1259,7 +1319,9 @@ export function withContent() {
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_VOD,
() =>
patchState(store, {
vodStreams: vod,
vodStreams: reloadAfterInitialization
? []
: vod,
vodStreamsPlaylistId: ctx.playlistId,
}),
() => ({ items: vod.length })
@@ -1298,7 +1360,9 @@ export function withContent() {
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_SERIES,
() =>
patchState(store, {
serialStreams: series,
serialStreams: reloadAfterInitialization
? []
: series,
isLoadingContent: false,
}),
() => ({ items: series.length })
@@ -1461,9 +1525,45 @@ export function withContent() {
/**
* Reload categories from database (after visibility changes)
*/
async reloadCategories(): Promise<void> {
/**
* Empties every catalog list at once — the parental lock
* relocking must not leave rows read while unlocked on
* screen for the duration of the filtered reload. Load
* states and import bookkeeping are untouched; the reload
* refills the lists.
*/
withholdCatalog(): void {
// Before any reload is awaited: an initial hydration
// still in flight read its rows under the previous lock
// state, and must not publish them while the category
// reload runs. The deferred reload refills afterwards.
if (!store.isContentInitialized()) {
reloadAfterInitialization = true;
}
patchState(store, {
liveCategories: [],
vodCategories: [],
serialCategories: [],
liveStreams: [],
vodStreams: [],
serialStreams: [],
});
},
/**
* @param shouldPublish answered right before each state
* patch; false drops the read (the caller's lock version
* moved on while it was in flight).
*/
async reloadCategories(
shouldPublishArg: () => boolean = () => true
): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
const shouldPublish = publishWhileCurrent(
ctx.playlistId,
shouldPublishArg
);
try {
const [live, vod, series] = await Promise.all([
@@ -1484,6 +1584,7 @@ export function withContent() {
),
]);
if (!shouldPublish()) return;
patchState(store, {
liveCategories: live,
vodCategories: vod,
@@ -1491,7 +1592,110 @@ export function withContent() {
serialCategories: series,
});
} catch (error) {
// Fail closed: the lists on screen were read under
// the previous lock state, so keeping them would
// keep locked category names visible. They are
// rebuilt by the next category load.
logger.error('Error reloading categories', error);
if (!shouldPublish()) return;
patchState(store, {
liveCategories: [],
vodCategories: [],
serialCategories: [],
});
}
},
/**
* Re-reads the already imported streams from the data source
* cache without touching the import bookkeeping. Used when
* the parental lock flips: the SQLite worker (Electron) and
* the PWA data source filter locked categories at read time,
* so the in-memory catalog must be rebuilt from them.
*/
async reloadCachedContent(
shouldPublishArg: () => boolean = () => true
): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) {
return;
}
if (!store.isContentInitialized()) {
// The initial hydration is still publishing rows it
// read under the previous lock state: withhold those
// and reload once it has settled.
reloadAfterInitialization = true;
deferredPublishGuard = shouldPublishArg;
return;
}
const shouldPublish = publishWhileCurrent(
ctx.playlistId,
shouldPublishArg
);
const loadStates = store.contentLoadStateByType();
// Each type on its own: one failing read must neither
// skip the remaining types nor keep its own rows, which
// were read under the previous lock state. A failed type
// is emptied and set back to `idle`, so the next visit
// loads it again (filtered) instead of showing a gap.
const failed: ContentType[] = [];
if (loadStates.live === 'ready') {
try {
const live = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'live'
)) as XtreamLiveStream[];
if (!shouldPublish()) return;
patchState(store, { liveStreams: live });
} catch (error) {
logger.error('Error reloading live streams', error);
if (!shouldPublish()) return;
patchState(store, { liveStreams: [] });
failed.push('live');
}
}
if (loadStates.vod === 'ready') {
try {
const vod = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'movie'
)) as XtreamVodStream[];
if (!shouldPublish()) return;
patchState(store, {
vodStreams: vod,
vodStreamsPlaylistId: ctx.playlistId,
});
} catch (error) {
logger.error('Error reloading VOD streams', error);
if (!shouldPublish()) return;
patchState(store, { vodStreams: [] });
failed.push('vod');
}
}
if (loadStates.series === 'ready') {
try {
const series = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'series'
)) as XtreamSerieItem[];
if (!shouldPublish()) return;
patchState(store, { serialStreams: series });
} catch (error) {
logger.error('Error reloading series', error);
if (!shouldPublish()) return;
patchState(store, { serialStreams: [] });
failed.push('series');
}
}
if (failed.length > 0 && shouldPublish()) {
const next = { ...store.contentLoadStateByType() };
for (const type of failed) {
next[type] = 'idle';
}
patchState(store, { contentLoadStateByType: next });
}
},
@@ -114,3 +114,72 @@ describe('withSearch renderer performance markers', () => {
expect(events).toEqual([]);
});
});
describe('withSearch refreshSearchResults', () => {
let searchContent: jest.Mock;
let store: InstanceType<typeof TestSearchStore>;
beforeEach(() => {
searchContent = jest.fn(async () => [{ xtream_id: 1 }]);
TestBed.configureTestingModule({
providers: [
TestSearchStore,
{ provide: XTREAM_DATA_SOURCE, useValue: { searchContent } },
],
});
store = TestBed.inject(TestSearchStore);
});
it('re-runs the last search with the parameters it was issued with', async () => {
await store.searchContent({
term: 'news',
types: ['live'],
excludeHidden: true,
});
searchContent.mockResolvedValueOnce([]);
await store.refreshSearchResults();
expect(searchContent).toHaveBeenLastCalledWith(
'playlist-1',
'news',
['live'],
true
);
expect(store.searchResults()).toEqual([]);
});
it('clears stored results without forgetting the last search', async () => {
await store.searchContent('news', ['live']);
expect(store.searchResults()).toHaveLength(1);
store.clearSearchResults();
expect(store.searchResults()).toEqual([]);
await store.refreshSearchResults();
expect(searchContent).toHaveBeenCalledTimes(2);
expect(store.searchResults()).toHaveLength(1);
});
it('retires a search still in flight when the results are cleared', async () => {
const pending = createDeferred<unknown[]>();
searchContent.mockReturnValueOnce(pending.promise);
const running = store.searchContent('news', ['live']);
store.clearSearchResults();
pending.resolve([{ xtream_id: 1 }]);
await running;
expect(store.searchResults()).toEqual([]);
});
it('does nothing without a previous search or after a reset', async () => {
await store.refreshSearchResults();
expect(searchContent).not.toHaveBeenCalled();
await store.searchContent('news', ['live']);
store.resetSearchResults();
await store.refreshSearchResults();
expect(searchContent).toHaveBeenCalledTimes(1);
});
});
@@ -81,6 +81,8 @@ export function withSearch() {
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
let searchRequestVersion = 0;
/** The last in-portal search, so it can be re-run as issued. */
let lastSearch: SearchContentParams | null = null;
return {
/**
@@ -110,12 +112,14 @@ export function withSearch() {
const requestVersion = ++searchRequestVersion;
if (!playlistId || !searchTerm.trim()) {
lastSearch = null;
patchState(store, {
searchResults: [],
isSearching: false,
});
return [];
}
lastSearch = { term: searchTerm, types, excludeHidden };
patchState(store, { isSearching: true });
@@ -157,6 +161,29 @@ export function withSearch() {
}
},
/**
* Drops the stored results; term, filters and the last search
* stay. Also retires a search still in flight: it was issued
* under the previous lock state and must not republish what
* was just cleared.
*/
clearSearchResults(): void {
searchRequestVersion++;
patchState(store, { searchResults: [] });
},
/**
* Re-runs the last in-portal search with its own parameters,
* so stored results reflect the current read filters (the
* parental lock) without the page having to search again.
*/
async refreshSearchResults(): Promise<void> {
if (!lastSearch) {
return;
}
await this.searchContent(lastSearch);
},
/**
* Set global search results (from external search)
*/
@@ -209,6 +236,7 @@ export function withSearch() {
*/
resetSearchResults(): void {
searchRequestVersion++;
lastSearch = null;
patchState(store, initialSearchState);
},
};
@@ -11,6 +11,12 @@
<div class="selection-info">
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
@if (showLocks()) {
<span class="selection-info__locks">
· {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
}
</div>
<div class="bulk-actions">
<button
@@ -74,17 +80,48 @@
[attr.data-category-id]="category.xtream_id"
(click)="toggleCategory(category)"
>
<mat-checkbox
[checked]="category.selected"
[aria-label]="category.name"
(click)="$event.stopPropagation()"
(change)="toggleCategory(category)"
>
</mat-checkbox>
@if (supportsVisibility) {
<mat-checkbox
[checked]="category.selected"
[aria-label]="category.name"
(click)="$event.stopPropagation()"
(change)="toggleCategory(category)"
>
</mat-checkbox>
}
<span class="category-name">{{ category.name }}</span>
<span class="item-count"
>({{ getItemCount(category) }})</span
>
@if (showLocks()) {
<button
type="button"
mat-icon-button
class="lock-toggle"
[class.lock-toggle--locked]="category.lockedDraft"
[attr.aria-pressed]="category.lockedDraft"
[attr.data-test-id]="
'category-lock-' + category.xtream_id
"
[matTooltip]="
(category.lockedDraft
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
[attr.aria-label]="
(category.lockedDraft
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
(click)="toggleLock(category, $event)"
>
<mat-icon>{{
category.lockedDraft ? 'lock' : 'lock_open'
}}</mat-icon>
</button>
}
</div>
} @empty {
<div class="empty-message">
@@ -168,3 +168,27 @@ mat-dialog-actions {
}
}
}
.selection-info__locks {
margin-left: 4px;
}
.lock-toggle {
width: 28px;
height: 28px;
min-width: 28px;
padding: 0;
line-height: 28px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&--locked {
color: var(--mat-sys-primary);
}
}
@@ -1,7 +1,14 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { DatabaseService, XCategoryFromDb } from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
ParentalLockService,
RuntimeCapabilitiesService,
XCategoryFromDb,
} from '@iptvnator/services';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
@@ -17,16 +24,26 @@ const categories: XCategoryFromDb[] = [
...category,
playlist_id: 'mock-playlist',
type: 'live',
locked: false,
}));
describe('CategoryManagementDialogComponent', () => {
let fixture: ComponentFixture<CategoryManagementDialogComponent>;
let component: CategoryManagementDialogComponent;
const db = {
getAllXtreamCategories: jest.fn(),
updateCategoryVisibility: jest.fn(),
};
const dataSource = {
getAllCategories: jest.fn(),
};
const dialogRef = { close: jest.fn() };
const parentalLock = {
enabled: signal(false),
active: signal(false),
lockedXtreamIds: jest.fn(() => [] as number[]),
setXtreamLocks: jest.fn(),
ensureLocksReadable: jest.fn(async () => true),
};
const data: CategoryManagementDialogData = {
playlistId: 'mock-playlist',
contentType: 'live',
@@ -35,8 +52,10 @@ describe('CategoryManagementDialogComponent', () => {
beforeEach(async () => {
jest.clearAllMocks();
db.getAllXtreamCategories.mockResolvedValue(categories);
dataSource.getAllCategories.mockResolvedValue(categories);
db.updateCategoryVisibility.mockResolvedValue(undefined);
parentalLock.enabled.set(false);
parentalLock.active.set(false);
data.contentType = 'live';
await TestBed.configureTestingModule({
imports: [
@@ -45,8 +64,14 @@ describe('CategoryManagementDialogComponent', () => {
],
providers: [
{ provide: DatabaseService, useValue: db },
{ provide: XTREAM_DATA_SOURCE, useValue: dataSource },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource: true },
},
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: ParentalLockService, useValue: parentalLock },
],
}).compileComponents();
fixture = TestBed.createComponent(CategoryManagementDialogComponent);
@@ -171,6 +196,49 @@ describe('CategoryManagementDialogComponent', () => {
expect(dialogRef.close).toHaveBeenCalledWith(true);
});
it('closes without saving when the session relocks while it is open', () => {
parentalLock.enabled.set(true);
fixture.detectChanges();
expect(dialogRef.close).not.toHaveBeenCalled();
parentalLock.active.set(true);
fixture.detectChanges();
expect(dialogRef.close).toHaveBeenCalledWith(false);
expect(db.updateCategoryVisibility).not.toHaveBeenCalled();
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
});
it('drops the lock draft when the session relocks during a pending save', async () => {
parentalLock.enabled.set(true);
component.showLocks.set(true);
fixture.detectChanges();
let finishVisibility: () => void = () => undefined;
db.updateCategoryVisibility.mockImplementationOnce(
() => new Promise<void>((resolve) => (finishVisibility = resolve))
);
component.deselectAll();
const saving = component.save();
parentalLock.active.set(true);
finishVisibility();
await saving;
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
});
it('offers no lock toggles, and saves no locks, while the lock store is unreadable', async () => {
parentalLock.enabled.set(true);
parentalLock.ensureLocksReadable.mockResolvedValueOnce(false);
await component.ngOnInit();
expect(component.showLocks()).toBe(false);
await component.save();
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
expect(dialogRef.close).toHaveBeenCalledWith(true);
});
it('discards pending bulk changes on cancel', () => {
component.searchTerm.set('FR');
component.selectAll();
@@ -194,7 +262,7 @@ describe('CategoryManagementDialogComponent', () => {
async (contentType, dbType) => {
data.contentType = contentType;
await component.ngOnInit();
expect(db.getAllXtreamCategories).toHaveBeenLastCalledWith(
expect(dataSource.getAllCategories).toHaveBeenLastCalledWith(
'mock-playlist',
dbType
);
@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
OnInit,
signal,
@@ -16,8 +17,15 @@ import {
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { DatabaseService, XCategoryFromDb } from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
ParentalLockService,
RuntimeCapabilitiesService,
XCategoryFromDb,
} from '@iptvnator/services';
import { createLogger } from '@iptvnator/portal/shared/util';
import { foldSearchText } from '@iptvnator/shared/interfaces';
@@ -29,6 +37,8 @@ export interface CategoryManagementDialogData {
interface CategoryWithSelection extends XCategoryFromDb {
selected: boolean;
/** Parental lock draft; only rendered while the lock is enabled. */
lockedDraft: boolean;
}
@Component({
@@ -39,6 +49,7 @@ interface CategoryWithSelection extends XCategoryFromDb {
MatCheckboxModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
TranslatePipe,
],
templateUrl: './category-management-dialog.component.html',
@@ -47,12 +58,32 @@ interface CategoryWithSelection extends XCategoryFromDb {
})
export class CategoryManagementDialogComponent implements OnInit {
private readonly dbService = inject(DatabaseService);
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly parentalLock = inject(ParentalLockService);
/**
* Hide/show is SQLite-backed and Electron-only; the PWA data source lists
* its raw categories so the lock toggles still have candidates there.
*/
readonly supportsVisibility = this.runtime.supportsXtreamSqliteDataSource;
private readonly snackBar = inject(MatSnackBar);
private readonly dialogRef = inject(
MatDialogRef<CategoryManagementDialogComponent>
);
readonly data = inject<CategoryManagementDialogData>(MAT_DIALOG_DATA);
private readonly logger = createLogger('CategoryManagementDialog');
/** Lock toggles exist only while the parental lock feature is on. */
/**
* Lock toggles, only once the lock store has been read: a draft built
* from the empty fail-closed snapshot would, if storage recovered by
* Save, replace the real locks with nothing.
*/
readonly showLocks = signal(false);
readonly lockedCount = computed(
() => this.categories().filter((c) => c.lockedDraft).length
);
readonly isLoading = signal(true);
readonly isSaving = signal(false);
readonly categories = signal<CategoryWithSelection[]>([]);
@@ -82,6 +113,17 @@ export class CategoryManagementDialogComponent implements OnInit {
this.filteredSelectedCount() === this.filteredCategories().length
);
constructor() {
// The PIN gate only covers opening: a relock (idle timer, Lock now)
// while the editor is open must take its locked names and its
// lock-rewriting Save away too.
effect(() => {
if (this.parentalLock.active()) {
this.dialogRef.close(false);
}
});
}
async ngOnInit(): Promise<void> {
await this.loadCategories();
}
@@ -89,11 +131,21 @@ export class CategoryManagementDialogComponent implements OnInit {
private async loadCategories(): Promise<void> {
try {
const type = this.getDbType();
this.showLocks.set(
this.parentalLock.enabled() &&
(await this.parentalLock.ensureLocksReadable())
);
const allCategories = await this.waitForCategories(type);
// The renderer's lock store is authoritative; the row's `locked`
// column is only its SQLite mirror.
const lockedIds = new Set(
this.parentalLock.lockedXtreamIds(this.data.playlistId, type)
);
this.categories.set(
allCategories.map((c) => ({
...c,
selected: !c.hidden,
lockedDraft: lockedIds.has(c.xtream_id),
}))
);
} catch (error) {
@@ -110,7 +162,7 @@ export class CategoryManagementDialogComponent implements OnInit {
const expectedCategoryCount = this.data.itemCounts.size;
while (true) {
const categories = await this.dbService.getAllXtreamCategories(
const categories = await this.dataSource.getAllCategories(
this.data.playlistId,
type
);
@@ -155,6 +207,15 @@ export class CategoryManagementDialogComponent implements OnInit {
);
}
toggleLock(category: CategoryWithSelection, event?: Event): void {
event?.stopPropagation();
this.categories.update((cats) =>
cats.map((c) =>
c.id === category.id ? { ...c, lockedDraft: !c.lockedDraft } : c
)
);
}
selectAll(): void {
this.setFilteredSelection(true);
}
@@ -181,21 +242,35 @@ export class CategoryManagementDialogComponent implements OnInit {
.filter((c) => c.selected)
.map((c) => c.id);
if (toHide.length > 0) {
if (this.supportsVisibility && toHide.length > 0) {
await this.dbService.updateCategoryVisibility(toHide, true);
}
if (toShow.length > 0) {
if (this.supportsVisibility && toShow.length > 0) {
await this.dbService.updateCategoryVisibility(toShow, false);
}
// A relock while the visibility writes ran closed the dialog:
// its lock draft is dropped. The lock store refuses a removal
// that still commits after a relock (it checks at commit time).
if (this.showLocks() && !this.parentalLock.active()) {
const saved = await this.parentalLock.setXtreamLocks(
this.data.playlistId,
this.getDbType(),
categories
.filter((c) => c.lockedDraft)
.map((c) => c.xtream_id)
);
if (!saved) {
throw new Error('Saving category locks failed');
}
}
this.dialogRef.close(true);
} catch (error) {
this.logger.error('Error saving category visibility', error);
inject(MatSnackBar).open(
'Failed to save category visibility',
'Close',
{ duration: 3000 }
);
this.snackBar.open('Failed to save category visibility', 'Close', {
duration: 3000,
});
} finally {
this.isSaving.set(false);
}
@@ -13,6 +13,7 @@ import {
TemplateRef,
computed,
effect,
untracked,
forwardRef,
inject,
OnDestroy,
@@ -94,6 +95,8 @@ import {
} from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import {
DatabaseService,
ParentalLockService,
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
@@ -167,6 +170,8 @@ export class LiveStreamLayoutComponent
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly parentalLock = inject(ParentalLockService);
private readonly databaseService = inject(DatabaseService);
private readonly liveAutoOpenState = inject(LiveStreamAutoOpenStateService);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
@@ -416,6 +421,14 @@ export class LiveStreamLayoutComponent
);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly activeLiveItemId = signal<number | null>(null);
/**
* Provider category id of the playing channel, for the parental lock.
* `null`: nothing playing (or no category); `'unknown'`: Electron has
* not resolved it yet (or cannot) — a relock then stops the channel.
*/
private activeLiveProviderCategoryId: number | null | 'unknown' = null;
/** Bumped per resolution so a slow hidden-category lookup cannot land on a later playback. */
private liveCategoryLookupGeneration = 0;
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.activeLiveItemId();
@@ -439,6 +452,39 @@ export class LiveStreamLayoutComponent
this.activeCatchupProgram.set(null);
}
});
// A relock (idle timer, Lock now) must stop a channel playing from a
// now-withheld category: the player is gated on `activePlayback`,
// not on the store selection the enforcement service clears — and
// that selection is also dropped by an ordinary category switch,
// which must keep the channel playing.
effect(() => {
this.parentalLock.version();
untracked(() => {
const categoryId = this.activeLiveProviderCategoryId;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (categoryId === null || !playlistId) {
return;
}
// Unresolved category: fail closed while the lock is active
// rather than keep a possibly locked stream running.
const withheld =
categoryId === 'unknown'
? this.parentalLock.active()
: this.parentalLock.isXtreamCategoryLocked(
playlistId,
'live',
categoryId
);
if (!withheld) {
return;
}
this.playbackRequestId += 1;
this.activePlayback.set(null);
this.activeLiveItemId.set(null);
this.activeCatchupProgram.set(null);
this.activeLiveProviderCategoryId = null;
});
});
effect((onCleanup) => {
const intervalId = window.setInterval(() => {
this.currentTimeMs.set(Date.now());
@@ -621,6 +667,8 @@ export class LiveStreamLayoutComponent
// store no-op.
if (!remote) this.selectLiveItemCategory(item);
this.activeLiveItemId.set(item.xtream_id);
this.activeLiveProviderCategoryId =
this.resolveLiveProviderCategoryId(item);
const playlist = this.xtreamStore.currentPlaylist();
this.activePlaylistId = playlist?.id ?? null;
this.activePlayback.set({
@@ -807,6 +855,62 @@ export class LiveStreamLayoutComponent
return [...this.channelNavigation.remoteChannels()];
}
/**
* The provider category id the parental lock is keyed by. Electron rows
* carry the SQLite category row id in `category_id` and the provider id
* on the category row (`xtream_id`); the PWA carries the provider id on
* both. A row whose category is not in the list resolves to null rather
* than to a guess that could collide with a locked provider id.
*/
private resolveLiveProviderCategoryId(
item: XtreamLiveChannelItem
): number | null | 'unknown' {
// Every resolution retires the lookups before it, including one for
// a same-id channel of another playlist that resolved synchronously.
const generation = ++this.liveCategoryLookupGeneration;
const categoryId = Number(item.category_id);
if (!Number.isFinite(categoryId)) {
return null;
}
if (!this.runtime.supportsXtreamSqliteDataSource) {
return categoryId;
}
const category = (this.xtreamStore.liveCategories?.() ?? []).find(
(candidate) =>
Number((candidate as { id?: number }).id) === categoryId
) as { xtream_id?: number } | undefined;
const providerId = Number(category?.xtream_id);
if (Number.isFinite(providerId)) {
return providerId;
}
// Not in the visible list — a hidden category (search can play its
// channels). Resolve through the unfiltered rows; until that lands
// the id is unknown and a relock stops the channel.
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (playlistId) {
void this.databaseService
.getAllXtreamCategories(playlistId, 'live')
.then((rows) => {
if (
generation !== this.liveCategoryLookupGeneration ||
this.xtreamStore.currentPlaylist()?.id !== playlistId ||
this.activeLiveItemId() !== item.xtream_id
) {
return;
}
const row = rows.find(
(candidate) => Number(candidate.id) === categoryId
);
const resolved = Number(row?.xtream_id);
if (Number.isFinite(resolved)) {
this.activeLiveProviderCategoryId = resolved;
}
})
.catch(() => undefined);
}
return 'unknown';
}
private selectLiveItemCategory(item: XtreamLiveChannelItem): void {
const categoryId = Number(item.category_id);
if (Number.isFinite(categoryId) && categoryId > 0) {
@@ -838,6 +942,8 @@ export class LiveStreamLayoutComponent
const requestId = ++this.playbackRequestId;
this.activeLiveItemId.set(item.xtream_id);
this.activeLiveProviderCategoryId =
this.resolveLiveProviderCategoryId(item);
const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl(
playlist.id,
{
@@ -0,0 +1,305 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { MockPipe } from 'ng-mocks';
import { TranslatePipe } from '@ngx-translate/core';
import { Subject, of } from 'rxjs';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import {
FavoritesService,
XtreamStore,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
EpgArchiveCopyService,
EpgArchiveDownloadService,
EpgListViewComponent,
EpgTimelineComponent,
} from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { GridListComponent } from '@iptvnator/portal/shared/ui';
import {
DatabaseService,
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import {
playlist,
sampleChannel,
StubEpgTimelineComponent,
StubGridListComponent,
StubPortalChannelsListComponent,
StubResizableDirective,
StubWebPlayerViewComponent,
} from './live-stream-layout-stubs.spec-data';
/**
* Parental lock: a relock stops the channel playing from a now-withheld
* category. Split from `live-stream-layout.component.spec.ts`, which sits at
* the spec line cap.
*/
describe('LiveStreamLayoutComponent parental lock', () => {
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
let component: LiveStreamLayoutComponent;
const emptyList = signal<unknown[]>([]);
const parentalLock = {
version: signal(0),
active: signal(false),
isXtreamCategoryLocked: jest.fn(() => false),
};
const databaseService = {
getAllXtreamCategories: jest.fn(async () => [] as unknown[]),
};
const runtime = { supportsXtreamSqliteDataSource: false };
const xtreamStore = {
getCategoriesBySelectedType: signal([
{ category_id: 1, category_name: 'News' },
]),
liveCategories: signal([{ id: 41, xtream_id: 7, name: 'Adult' }]),
getCategoryItemCounts: signal(new Map<number, number>([[1, 1]])),
getPaginatedContent: emptyList,
hasMoreContent: signal(false),
epgItems: emptyList,
currentEpgItem: signal(null),
isLoadingEpg: signal(false),
selectedTypeContentLoading: signal(false),
selectedCategoryId: signal<number | null>(1),
selectedContentType: signal('live'),
selectedItem: signal(null),
currentPlaylist: signal(playlist),
liveStreams: emptyList,
isContentInitialized: signal(true),
selectItemsFromSelectedCategory: jest.fn(() => []),
constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'),
openPlayer: jest.fn(),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
loadMoreContent: jest.fn(),
};
beforeEach(async () => {
parentalLock.version.set(0);
parentalLock.active.set(false);
parentalLock.isXtreamCategoryLocked.mockReset();
parentalLock.isXtreamCategoryLocked.mockReturnValue(false);
databaseService.getAllXtreamCategories.mockReset();
databaseService.getAllXtreamCategories.mockResolvedValue([]);
runtime.supportsXtreamSqliteDataSource = false;
await TestBed.configureTestingModule({
imports: [LiveStreamLayoutComponent, NoopAnimationsModule],
providers: [
{
provide: EpgArchiveDownloadService,
useValue: { start: jest.fn() },
},
{
provide: EpgArchiveCopyService,
useValue: { copy: jest.fn() },
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
queryParamMap: convertToParamMap({}),
},
queryParamMap: of(convertToParamMap({})),
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{
provide: Router,
useValue: {
events: new Subject().asObservable(),
navigate: jest.fn(),
},
},
{ provide: XtreamStore, useValue: xtreamStore },
{
provide: FavoritesService,
useValue: { getFavorites: jest.fn(() => of([])) },
},
{
provide: XtreamUrlService,
useValue: {
constructAutoLiveTsUrl: jest.fn(() => undefined),
resolveCatchupUrl: jest.fn(),
},
},
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{ provide: DatabaseService, useValue: databaseService },
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openExternalPlayback: jest.fn(),
},
},
{ provide: ParentalLockService, useValue: parentalLock },
],
})
.overrideComponent(LiveStreamLayoutComponent, {
remove: {
imports: [
EpgListViewComponent,
EpgTimelineComponent,
GridListComponent,
PortalChannelsListComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
},
add: {
imports: [
StubEpgTimelineComponent,
StubGridListComponent,
StubPortalChannelsListComponent,
StubResizableDirective,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
StubWebPlayerViewComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
component = fixture.componentInstance;
TestBed.inject(LiveLayoutSidebarStateService).setState(
'portal',
'expanded'
);
fixture.detectChanges();
});
afterEach(() => fixture.destroy());
function lock(categoryId: number): void {
parentalLock.active.set(true);
parentalLock.isXtreamCategoryLocked.mockImplementation(
(_playlistId: string, _type: string, id: number) =>
id === categoryId
);
parentalLock.version.update((value) => value + 1);
fixture.detectChanges();
}
it('stops the playing channel once its category is withheld', () => {
component.playLive({ ...sampleChannel, category_id: 7 });
expect(component.activePlayback()).not.toBeNull();
// Locks edited while unlocked: nothing is withheld yet.
parentalLock.version.update((value) => value + 1);
fixture.detectChanges();
expect(component.activePlayback()).not.toBeNull();
lock(7);
expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith(
'playlist-1',
'live',
7
);
expect(component.activePlayback()).toBeNull();
expect(component.playbackSessionKey()).toBe('');
});
it('keeps a channel from an unlocked category playing', () => {
component.playLive({ ...sampleChannel, category_id: 3 });
lock(7);
expect(component.activePlayback()).not.toBeNull();
});
it('resolves a hidden category through the unfiltered rows before judging it', async () => {
runtime.supportsXtreamSqliteDataSource = true;
// Row 99 is hidden: absent from liveCategories, present in the
// unfiltered table with provider id 9.
databaseService.getAllXtreamCategories.mockResolvedValue([
{ id: 99, xtream_id: 9 },
]);
component.playLive({ ...sampleChannel, category_id: 99 });
await fixture.whenStable();
expect(databaseService.getAllXtreamCategories).toHaveBeenCalledWith(
'playlist-1',
'live'
);
lock(7);
expect(component.activePlayback()).not.toBeNull();
lock(9);
expect(component.activePlayback()).toBeNull();
});
it('ignores a hidden-category lookup that lands after a later playback', async () => {
runtime.supportsXtreamSqliteDataSource = true;
let resolveLookup: (rows: unknown[]) => void = () => undefined;
databaseService.getAllXtreamCategories.mockReturnValueOnce(
new Promise<unknown[]>((resolve) => (resolveLookup = resolve))
);
// Playlist 1: hidden category 99, lookup in flight.
component.playLive({ ...sampleChannel, category_id: 99 });
// Playlist 2: a channel with the same provider id whose category is
// visible (row 41 → provider 7) resolves synchronously.
xtreamStore.currentPlaylist.set({ ...playlist, id: 'playlist-2' });
fixture.detectChanges();
component.playLive({ ...sampleChannel, category_id: 41 });
expect(component.activePlayback()).not.toBeNull();
// The stale lookup answers for playlist 1 with provider 9.
resolveLookup([{ id: 99, xtream_id: 9 }]);
await fixture.whenStable();
lock(9);
expect(component.activePlayback()).not.toBeNull();
lock(7);
expect(component.activePlayback()).toBeNull();
xtreamStore.currentPlaylist.set(playlist);
});
it('stops a channel whose category is still unresolved when the lock activates', () => {
runtime.supportsXtreamSqliteDataSource = true;
databaseService.getAllXtreamCategories.mockReturnValue(
new Promise(() => undefined)
);
component.playLive({ ...sampleChannel, category_id: 99 });
lock(7);
expect(component.activePlayback()).toBeNull();
});
it('maps the SQLite category row id to the provider id it is locked by', () => {
runtime.supportsXtreamSqliteDataSource = true;
component.playLive({ ...sampleChannel, category_id: 41 });
lock(7);
expect(component.activePlayback()).toBeNull();
});
});
@@ -0,0 +1,215 @@
import { TestBed } from '@angular/core/testing';
import {
ActivatedRouteSnapshot,
convertToParamMap,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
ParentalLockService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { parentalLockXtreamCategoryGuard } from './parental-lock-category.guard';
function createRoute(
playlistId: string,
categoryId: string,
itemParams: Record<string, string> = {}
) {
const parent = {
paramMap: convertToParamMap({ id: playlistId }),
} as ActivatedRouteSnapshot;
const route = {
paramMap: convertToParamMap({ categoryId, ...itemParams }),
} as ActivatedRouteSnapshot;
Object.defineProperty(route, 'pathFromRoot', {
value: [parent, route],
});
return route;
}
describe('parentalLockXtreamCategoryGuard', () => {
let parentalLock: {
initialize: jest.Mock;
active: jest.Mock;
isXtreamCategoryLocked: jest.Mock;
requestUnlock: jest.Mock;
};
let databaseService: { getAllXtreamCategories: jest.Mock };
let runtime: { supportsXtreamSqliteDataSource: boolean };
let router: { createUrlTree: jest.Mock };
let dataSource: {
getContentByXtreamId: jest.Mock;
getPlaylist: jest.Mock;
getContent: jest.Mock;
};
beforeEach(() => {
parentalLock = {
initialize: jest.fn().mockResolvedValue(undefined),
active: jest.fn(() => true),
isXtreamCategoryLocked: jest.fn(() => false),
requestUnlock: jest.fn().mockResolvedValue(false),
};
databaseService = {
getAllXtreamCategories: jest
.fn()
.mockResolvedValue([
{ id: 12, xtream_id: 900, type: 'movies' },
]),
};
runtime = { supportsXtreamSqliteDataSource: true };
router = { createUrlTree: jest.fn(() => ({}) as UrlTree) };
dataSource = {
getContentByXtreamId: jest.fn().mockResolvedValue(null),
getPlaylist: jest.fn().mockResolvedValue({
serverUrl: 'http://panel.example',
username: 'u',
password: 'p',
}),
getContent: jest.fn().mockResolvedValue([]),
};
TestBed.configureTestingModule({
providers: [
{ provide: XTREAM_DATA_SOURCE, useValue: dataSource },
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: DatabaseService, useValue: databaseService },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{ provide: Router, useValue: router },
],
});
});
function run(
section: 'live' | 'vod' | 'series',
categoryId: string,
itemParams: Record<string, string> = {}
) {
return TestBed.runInInjectionContext(() =>
parentalLockXtreamCategoryGuard(section)(
createRoute('playlist-1', categoryId, itemParams),
{} as RouterStateSnapshot
)
);
}
it('checks a detail item against its own category, not the one in the URL', async () => {
// Category row 12 (provider 900) is locked; row 13 (provider 901) is
// not. The URL names the unlocked one but the movie belongs to the
// locked one.
databaseService.getAllXtreamCategories.mockResolvedValue([
{ id: 12, xtream_id: 900, type: 'movies' },
{ id: 13, xtream_id: 901, type: 'movies' },
]);
parentalLock.isXtreamCategoryLocked.mockImplementation(
(_playlist: string, _type: string, xtreamId: number) =>
xtreamId === 900
);
dataSource.getContentByXtreamId.mockResolvedValue({ category_id: 12 });
const result = await run('vod', '13', { vodId: '555' });
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
555,
'playlist-1',
'movie'
);
expect(parentalLock.requestUnlock).toHaveBeenCalled();
expect(result).not.toBe(true);
// A movie in an unlocked category passes without a prompt.
parentalLock.requestUnlock.mockClear();
dataSource.getContentByXtreamId.mockResolvedValue({ category_id: 13 });
await expect(run('vod', '13', { vodId: '556' })).resolves.toBe(true);
expect(parentalLock.requestUnlock).not.toHaveBeenCalled();
});
it('lets an unlocked category-only route through without an item check', async () => {
await expect(run('vod', '13')).resolves.toBe(true);
await expect(run('series', '13')).resolves.toBe(true);
expect(dataSource.getContentByXtreamId).not.toHaveBeenCalled();
expect(parentalLock.requestUnlock).not.toHaveBeenCalled();
});
it('passes through while the lock is inactive without touching the database', async () => {
parentalLock.active.mockReturnValue(false);
await expect(run('vod', '12')).resolves.toBe(true);
expect(databaseService.getAllXtreamCategories).not.toHaveBeenCalled();
});
it('maps the Electron row id to the provider id and redirects a refused locked category', async () => {
parentalLock.isXtreamCategoryLocked.mockReturnValue(true);
const result = await run('vod', '12');
expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith(
'playlist-1',
'movies',
900
);
expect(parentalLock.requestUnlock).toHaveBeenCalled();
expect(router.createUrlTree).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'playlist-1',
'vod',
]);
expect(result).not.toBe(true);
});
it('lets an entered PIN through', async () => {
parentalLock.isXtreamCategoryLocked.mockReturnValue(true);
parentalLock.requestUnlock.mockResolvedValue(true);
await expect(run('series', '12')).resolves.toBe(true);
expect(router.createUrlTree).not.toHaveBeenCalled();
});
it('hydrates the PWA session cache before judging a detail item on a cold navigation', async () => {
runtime.supportsXtreamSqliteDataSource = false;
parentalLock.isXtreamCategoryLocked.mockImplementation(
(_playlist: string, _type: string, xtreamId: number) =>
xtreamId === 900
);
dataSource.getContentByXtreamId
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ category_id: '900' });
const result = await run('vod', '901', { vodId: '555' });
expect(dataSource.getContent).toHaveBeenCalledWith(
'playlist-1',
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
'movie'
);
expect(parentalLock.requestUnlock).toHaveBeenCalled();
expect(result).not.toBe(true);
});
it('fails closed for a detail item the catalog cannot place', async () => {
dataSource.getContentByXtreamId.mockResolvedValue(null);
const result = await run('series', '13', { serialId: '777' });
expect(parentalLock.requestUnlock).toHaveBeenCalled();
expect(result).not.toBe(true);
});
it('uses the route id as the provider id in the PWA', async () => {
runtime.supportsXtreamSqliteDataSource = false;
await run('live', '77');
expect(databaseService.getAllXtreamCategories).not.toHaveBeenCalled();
expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith(
'playlist-1',
'live',
77
);
});
});
@@ -0,0 +1,153 @@
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
UrlTree,
} from '@angular/router';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
ParentalLockService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { ParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces';
type XtreamCategorySection = 'live' | 'vod' | 'series';
const CATEGORY_TYPE_BY_SECTION: Record<
XtreamCategorySection,
ParentalLockXtreamCategoryType
> = {
live: 'live',
vod: 'movies',
series: 'series',
};
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 Xtream category off the screen even when it is
* reached by URL — a bookmark, a typed address or a stale in-app link — and
* not through the (already filtered) category rail. The lock is keyed by the
* provider category id; in Electron the route carries the SQLite row id, so
* the guard maps it through the unfiltered category read (which only the
* guard sees). Detail routes additionally check the ITEM's own category: a
* locked movie paired with an unlocked category id in the URL is still a
* locked movie. A locked target prompts for the PIN; a refusal redirects to
* the section root instead of rendering the category or its detail.
*/
export function parentalLockXtreamCategoryGuard(
section: XtreamCategorySection
): CanActivateFn {
return async (route): Promise<boolean | UrlTree> => {
const parentalLock = inject(ParentalLockService);
const router = inject(Router);
const databaseService = inject(DatabaseService);
const runtime = inject(RuntimeCapabilitiesService);
const dataSource = inject(XTREAM_DATA_SOURCE);
await parentalLock.initialize();
if (!parentalLock.active()) {
return true;
}
const playlistId = findPlaylistId(route);
if (!playlistId) {
return true;
}
const categoryType = CATEGORY_TYPE_BY_SECTION[section];
const rows = runtime.supportsXtreamSqliteDataSource
? await databaseService.getAllXtreamCategories(
playlistId,
categoryType
)
: null;
// Electron routes and content rows carry SQLite category row ids;
// the PWA carries provider ids everywhere.
const toProviderId = (categoryId: number): number | null =>
rows === null
? categoryId
: (rows.find((candidate) => candidate.id === categoryId)
?.xtream_id ?? null);
const isLocked = (categoryId: number): boolean => {
const xtreamId = toProviderId(categoryId);
return (
xtreamId !== null &&
parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
xtreamId
)
);
};
const routeCategoryId = Number(route.paramMap.get('categoryId'));
let locked =
Number.isFinite(routeCategoryId) && isLocked(routeCategoryId);
// Category-only routes carry no item: `Number(null)` would be 0 and
// send every unlocked category through the (failing) item check.
const rawItemId =
route.paramMap.get('vodId') ?? route.paramMap.get('serialId');
const itemId = rawItemId === null ? null : Number(rawItemId);
if (
!locked &&
section !== 'live' &&
itemId !== null &&
Number.isFinite(itemId)
) {
const contentType = section === 'vod' ? 'movie' : 'series';
let item = await dataSource.getContentByXtreamId(
itemId,
playlistId,
contentType
);
if (!item && rows === null) {
// PWA: the session cache is empty on a cold navigation, so
// hydrate it the way the route session would before judging.
const playlist = await dataSource.getPlaylist(playlistId);
if (playlist) {
await dataSource.getContent(
playlistId,
{
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
contentType
);
item = await dataSource.getContentByXtreamId(
itemId,
playlistId,
contentType
);
}
}
const itemCategoryId = Number(item?.category_id);
// An item the catalog cannot place fails closed: while the lock
// is active an unknown title is not proof of an unlocked one.
locked =
!Number.isFinite(itemCategoryId) || isLocked(itemCategoryId);
}
if (!locked || (await parentalLock.requestUnlock())) {
return true;
}
return router.createUrlTree([
'/workspace',
'xtreams',
playlistId,
section,
]);
};
}
@@ -5,6 +5,7 @@ import {
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
ParentalLockService,
SettingsStore,
StreamProbeService,
VodSourcePinService,
@@ -42,6 +43,7 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
const playbackLive = signal(false);
const playbackStartBlocked = signal(false);
const vodAutoFailover = signal(false);
const lockVersion = signal(0);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
@@ -77,6 +79,7 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
movie.set(null);
playbackStartBlocked.set(false);
vodAutoFailover.set(false);
lockVersion.set(0);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
@@ -96,6 +99,10 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
{
provide: ParentalLockService,
useValue: { version: lockVersion },
},
],
});
@@ -131,6 +138,33 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
expect(discovery.discover).toHaveBeenCalledTimes(2);
});
it('drops the discovered sources and rediscovers when the lock version changes', async () => {
discovery.discover.mockResolvedValue({
sources: [ALT_TWO],
matchKind: 'title-year',
});
movie.set(MOVIE_A);
await flushEffects();
expect(service.sources().some((row) => row.id === ALT_TWO.id)).toBe(
true
);
// Lock now: ALT_TWO's category may be locked; the worker answers
// the rediscovery under the new lock state.
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
lockVersion.set(1);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(2);
expect(service.sources().some((row) => row.id === ALT_TWO.id)).toBe(
false
);
await expect(service.play(ALT_TWO.id)).resolves.toBe(false);
});
it('rediscovers the same movie once enrichment describes it better', async () => {
movie.set(MOVIE_A);
await flushEffects();
@@ -5,6 +5,7 @@ import {
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
ParentalLockService,
SettingsStore,
StreamProbeService,
VodSourcePinService,
@@ -124,6 +125,7 @@ export function setupVodMultiSourceHost() {
const playbackLive = signal(false);
const playbackStartBlocked = signal(false);
const vodAutoFailover = signal(false);
const lockVersion = signal(0);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
@@ -135,6 +137,7 @@ export function setupVodMultiSourceHost() {
playbackLive,
playbackStartBlocked,
vodAutoFailover,
lockVersion,
startPlayback,
discovery,
resolver,
@@ -150,6 +153,7 @@ export function setupVodMultiSourceHost() {
playbackLive.set(false);
playbackStartBlocked.set(false);
vodAutoFailover.set(false);
lockVersion.set(0);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
@@ -169,6 +173,10 @@ export function setupVodMultiSourceHost() {
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
{
provide: ParentalLockService,
useValue: { version: lockVersion },
},
],
});
@@ -5,6 +5,7 @@ import {
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
ParentalLockService,
SettingsStore,
StreamProbeService,
VodSourcePinService,
@@ -42,6 +43,7 @@ describe('VodMultiSourceHostService', () => {
const playbackLive = signal(false);
const playbackStartBlocked = signal(false);
const vodAutoFailover = signal(false);
const lockVersion = signal(0);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
@@ -91,6 +93,7 @@ describe('VodMultiSourceHostService', () => {
movie.set(null);
playbackStartBlocked.set(false);
vodAutoFailover.set(false);
lockVersion.set(0);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
@@ -110,6 +113,10 @@ describe('VodMultiSourceHostService', () => {
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
{
provide: ParentalLockService,
useValue: { version: lockVersion },
},
],
});
@@ -14,6 +14,7 @@ import {
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
ParentalLockService,
SettingsStore,
StreamProbeService,
VodSourcePinService,
@@ -97,6 +98,7 @@ export class VodMultiSourceHostService {
private readonly probes = inject(StreamProbeService);
private readonly probeCache = inject(VodSourceProbeCacheService);
private readonly settingsStore = inject(SettingsStore);
private readonly parentalLock = inject(ParentalLockService);
/**
* At most this many availability checks in flight at once. Each check is
@@ -116,6 +118,7 @@ export class VodMultiSourceHostService {
/** Bumped by every switch: a slower one must not overwrite a newer. */
private switchToken = 0;
private lastMovieKey: string | null = null;
private lastLockVersion: number | null = null;
private movieIdentity: string | null = null;
/** The row standing for the playlist the route is on. */
private routeSourceId: string | null = null;
@@ -170,6 +173,24 @@ export class VodMultiSourceHostService {
effect(() => {
const movie = bindings.movie();
// A lock change starts a fresh session: alternatives discovered
// under the previous lock state may sit in a now-locked
// category, and a discovery or switch still in flight must not
// publish or play them. Rediscovery reads through the worker's
// new lock state.
const lockVersion = this.parentalLock.version();
if (
this.lastLockVersion !== null &&
this.lastLockVersion !== lockVersion
) {
this.discoveryToken++;
this.sessionToken++;
this.movieIdentity = null;
this.lastMovieKey = null;
this.controller = new VodMultiSourceController();
this._sources.set([]);
}
this.lastLockVersion = lockVersion;
if (!movie) {
// Navigating away empties the identity before the next movie's
// `load()` runs, so bumping here — not only there — closes the
@@ -1,4 +1,5 @@
import { Route } from '@angular/router';
import { parentalLockXtreamCategoryGuard } from './parental-lock-category.guard';
import { provideXtreamCatalogFacade } from './xtream-catalog-facade.service';
import { provideXtreamWorkspaceRouteSession } from './xtream-workspace-route-session.service';
@@ -85,6 +86,9 @@ export function createXtreamRoutes(): Route[] {
},
{
path: 'live/:categoryId',
canActivate: [
parentalLockXtreamCategoryGuard('live'),
],
loadComponent: loadLiveStreamLayoutComponent,
},
{
@@ -98,11 +102,17 @@ export function createXtreamRoutes(): Route[] {
},
{
path: ':categoryId',
canActivate: [
parentalLockXtreamCategoryGuard('vod'),
],
loadComponent:
loadCategoryContentViewComponent,
},
{
path: ':categoryId/:vodId',
canActivate: [
parentalLockXtreamCategoryGuard('vod'),
],
loadComponent: loadVodDetailsRouteComponent,
},
],
@@ -118,11 +128,21 @@ export function createXtreamRoutes(): Route[] {
},
{
path: ':categoryId',
canActivate: [
parentalLockXtreamCategoryGuard(
'series'
),
],
loadComponent:
loadCategoryContentViewComponent,
},
{
path: ':categoryId/:serialId',
canActivate: [
parentalLockXtreamCategoryGuard(
'series'
),
],
loadComponent: loadSerialDetailsComponent,
},
],
+1
View File
@@ -9,6 +9,7 @@ export * from './lib/playback-position-runtime-bridge.service';
export * from './lib/playback-position.service';
export * from './lib/playlist-delete-cleanup.token';
export * from './lib/playlist-delete-action.service';
export * from './lib/parental-lock';
export * from './lib/playlist-backup.service';
export * from './lib/playlist-cross-context-lock';
export * from './lib/playlist-refresh.service';
@@ -1,8 +1,12 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { CatalogTitleMatch } from '@iptvnator/shared/interfaces';
import {
CatalogTitleMatchService,
groupTitleMatchesByKey,
pickTitleMatch,
} from './catalog-title-match.service';
import { ParentalLockService } from './parental-lock/parental-lock.service';
describe('catalog title matching', () => {
const match = (
@@ -188,3 +192,31 @@ describe('catalog title matching', () => {
});
});
});
describe('CatalogTitleMatchService parental lock', () => {
afterEach(() => {
delete (window as { electron?: unknown }).electron;
});
it.each([false, true])(
'asks the worker only while the lock does not withhold everything (%s)',
async (withholds) => {
const dbMatchTitles = jest.fn().mockResolvedValue([]);
(window as { electron?: unknown }).electron = { dbMatchTitles };
TestBed.configureTestingModule({
providers: [
{
provide: ParentalLockService,
useValue: { withholdsEverything: signal(withholds) },
},
],
});
await TestBed.inject(CatalogTitleMatchService).matchTitles([
'Dune',
]);
expect(dbMatchTitles).toHaveBeenCalledTimes(withholds ? 0 : 1);
}
);
});
@@ -1,9 +1,10 @@
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import {
CatalogTitleMatch,
normalizeTitleKeys,
titleYearsCompatible,
} from '@iptvnator/shared/interfaces';
import { ParentalLockService } from './parental-lock/parental-lock.service';
/** What one caller is looking for, in catalog-match terms */
export interface CatalogTitleLookup {
@@ -99,6 +100,8 @@ export function pickTitleMatch(
*/
@Injectable({ providedIn: 'root' })
export class CatalogTitleMatchService {
private readonly parentalLock = inject(ParentalLockService);
get isAvailable(): boolean {
return (
typeof window !== 'undefined' &&
@@ -107,7 +110,14 @@ export class CatalogTitleMatchService {
}
async matchTitles(titles: string[]): Promise<CatalogTitleMatch[]> {
if (!this.isAvailable || titles.length === 0) {
// The worker filters locked rows by 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 ||
titles.length === 0 ||
this.parentalLock.withholdsEverything()
) {
return [];
}
@@ -24,6 +24,8 @@ export interface XCategoryFromDb {
type: 'movies' | 'live' | 'series';
xtream_id: number;
hidden: boolean;
/** Parental lock index; see ParentalLockService. */
locked: boolean;
}
export interface XtreamContent {
@@ -429,16 +431,43 @@ export class DatabaseService {
playlistId: string,
categories: XtreamCategory[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[]
): Promise<void> {
await window.electron.dbSaveCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
hiddenCategoryXtreamIds,
lockedCategoryXtreamIds
);
}
/**
* Re-stamps the parental lock index of one playlist/type from provider
* category ids. False when the bridge is missing or the write failed.
*/
async setCategoryLocks(
playlistId: string,
type: 'live' | 'movies' | 'series',
lockedXtreamIds: number[]
): Promise<boolean> {
if (typeof window.electron?.dbSetCategoryLocks !== 'function') {
return false;
}
try {
await window.electron.dbSetCategoryLocks(
playlistId,
type,
lockedXtreamIds
);
return true;
} catch (error) {
console.error('Error updating category locks:', error);
return false;
}
}
/**
* Get all categories for a playlist (including hidden, for management dialog)
*/
@@ -539,12 +568,20 @@ export class DatabaseService {
}
async getAppState(key: string): Promise<string | null> {
return (await this.readAppState(key))?.value ?? null;
}
/**
* Like {@link getAppState}, but a failed read (no bridge, rejected IPC)
* comes back as `null` instead of looking like an absent key.
*/
async readAppState(key: string): Promise<{ value: string | null } | null> {
if (typeof window.electron?.dbGetAppState !== 'function') {
return null;
}
try {
return await window.electron.dbGetAppState(key);
return { value: await window.electron.dbGetAppState(key) };
} catch (error) {
console.error('Error getting app state:', error);
return null;
@@ -0,0 +1,6 @@
export * from './parental-lock-idle-timer';
export * from './parental-lock-prompt.token';
export * from './parental-lock-lock-store.service';
export * from './parental-lock-playlist-cleanup';
export * from './parental-lock-storage';
export * from './parental-lock.service';
@@ -0,0 +1,94 @@
import { signal } from '@angular/core';
/**
* Electron bridge calls of the parental lock. Both are no-ops in the PWA,
* where there is no main process to inform.
*/
/**
* Tells the SQLite worker (through main) whether locked rows are withheld.
* Resolves false when the IPC rejected: the worker keeps its previous
* state, so the caller must not trust worker reads while locked.
*/
export async function syncParentalLockStateToMainProcess(
active: boolean
): Promise<boolean> {
const bridge = window.electron;
if (typeof bridge?.setParentalLockState !== 'function') {
return true;
}
try {
await bridge.setParentalLockState(active);
return true;
} catch (error) {
console.error('Failed to sync the parental lock state.', error);
return false;
}
}
/**
* Mirrors the feature switch into electron-conf so a reloaded renderer and a
* restarted worker start locked while the feature is on. Resolves false
* when the main process could not persist it — the caller must then not
* report the switch as changed, or a reload would start from the old
* mirror. True in the PWA, which has no mirror.
*/
export async function mirrorParentalLockEnabledSetting(
enabled: boolean
): Promise<boolean> {
const bridge = window.electron;
if (typeof bridge?.updateSettings !== 'function') {
return true;
}
try {
await bridge.updateSettings({ parentalLockEnabled: enabled });
return true;
} catch (error) {
console.error('Failed to mirror the parental lock switch.', error);
return false;
}
}
/**
* Electron reads Xtream through the SQLite worker, but the bridge lacks the
* lock-state or index IPC (a partial or older preload): the worker cannot
* withhold locked rows, so a locked session withholds everything.
*/
export function isParentalLockWorkerFilterMissing(runtime: {
supportsXtreamSqliteDataSource: boolean;
supportsParentalLockSqliteFilter: boolean;
}): boolean {
const missing =
runtime.supportsXtreamSqliteDataSource &&
!runtime.supportsParentalLockSqliteFilter;
if (missing) {
console.error(
'The parental lock bridge is incomplete; locked sessions withhold every category.'
);
}
return missing;
}
/**
* Whether the SQLite worker learned the current lock state. A rejected
* sync leaves the worker in its previous state, so a locked session must
* not trust worker reads until a later sync succeeds.
*/
export class ParentalLockWorkerSync {
readonly failed = signal(false);
/** Bumps whenever `failed` changes; part of the lock version. */
readonly changes = signal(0);
/**
* Syncs `active`. A sync superseded by a newer transition (`current()`
* no longer equals `active`) changes nothing.
*/
async sync(active: boolean, current: () => boolean): Promise<void> {
const failed = !(await syncParentalLockStateToMainProcess(active));
if (current() !== active || this.failed() === failed) {
return;
}
this.failed.set(failed);
this.changes.update((value) => value + 1);
}
}
@@ -0,0 +1,144 @@
import {
PARENTAL_LOCK_BUSY_RECHECK_MS,
ParentalLockIdleTimer,
} from './parental-lock-idle-timer';
interface FakeScheduler {
now: number;
timers: Map<number, { at: number; callback: () => void }>;
advance(ms: number): void;
}
function createScheduler(): FakeScheduler {
let nextHandle = 1;
const scheduler: FakeScheduler = {
now: 0,
timers: new Map(),
advance(ms: number) {
const target = scheduler.now + ms;
while (true) {
const due = [...scheduler.timers.entries()]
.filter(([, timer]) => timer.at <= target)
.sort((a, b) => a[1].at - b[1].at)[0];
if (!due) {
break;
}
scheduler.timers.delete(due[0]);
scheduler.now = due[1].at;
due[1].callback();
}
scheduler.now = target;
},
};
return Object.assign(scheduler, {
setTimeout: (callback: () => void, delay: number) => {
const handle = nextHandle++;
scheduler.timers.set(handle, {
at: scheduler.now + delay,
callback,
});
return handle;
},
clearTimeout: (handle: number) => {
scheduler.timers.delete(handle);
},
});
}
function createTarget() {
const listeners = new Map<
string,
Set<EventListenerOrEventListenerObject>
>();
return {
listeners,
addEventListener: jest.fn((type: string, listener) => {
listeners.set(
type,
(listeners.get(type) ?? new Set()).add(listener)
);
}),
removeEventListener: jest.fn((type: string, listener) => {
listeners.get(type)?.delete(listener);
}),
fire(type: string) {
for (const listener of listeners.get(type) ?? []) {
(listener as EventListener)(new Event(type));
}
},
};
}
function createTimer(options: { busy?: () => boolean } = {}) {
const scheduler = createScheduler() as FakeScheduler & {
setTimeout: (callback: () => void, delay: number) => number;
clearTimeout: (handle: number) => void;
};
const target = createTarget();
const onExpire = jest.fn();
const timer = new ParentalLockIdleTimer({
onExpire,
isBusy: options.busy,
now: () => scheduler.now,
setTimeout: scheduler.setTimeout,
clearTimeout: scheduler.clearTimeout,
target: target as unknown as Document,
});
return { timer, scheduler, target, onExpire };
}
describe('ParentalLockIdleTimer', () => {
it('expires after the configured minutes without interaction', () => {
const { timer, scheduler, onExpire } = createTimer();
timer.arm(15);
scheduler.advance(14 * 60_000);
expect(onExpire).not.toHaveBeenCalled();
scheduler.advance(60_000);
expect(onExpire).toHaveBeenCalledTimes(1);
expect(timer.armed).toBe(false);
});
it('restarts the countdown on user interaction', () => {
const { timer, scheduler, target, onExpire } = createTimer();
timer.arm(5);
scheduler.advance(4 * 60_000);
target.fire('keydown');
scheduler.advance(4 * 60_000);
expect(onExpire).not.toHaveBeenCalled();
scheduler.advance(60_000);
expect(onExpire).toHaveBeenCalledTimes(1);
});
it('defers expiry while playback is busy and fires once it stops', () => {
let busy = true;
const { timer, scheduler, onExpire } = createTimer({
busy: () => busy,
});
timer.arm(5);
scheduler.advance(5 * 60_000);
expect(onExpire).not.toHaveBeenCalled();
scheduler.advance(PARENTAL_LOCK_BUSY_RECHECK_MS);
expect(onExpire).not.toHaveBeenCalled();
busy = false;
scheduler.advance(PARENTAL_LOCK_BUSY_RECHECK_MS);
expect(onExpire).toHaveBeenCalledTimes(1);
});
it('never fires with 0 minutes and stops listening once disarmed', () => {
const { timer, scheduler, target, onExpire } = createTimer();
timer.arm(0);
expect(timer.armed).toBe(false);
expect(target.addEventListener).not.toHaveBeenCalled();
timer.arm(1);
expect(target.addEventListener).toHaveBeenCalled();
timer.disarm();
scheduler.advance(10 * 60_000);
expect(onExpire).not.toHaveBeenCalled();
expect(target.removeEventListener).toHaveBeenCalled();
});
});
@@ -0,0 +1,148 @@
/**
* Re-lock timer for the parental lock.
*
* Counts minutes since the last user interaction while unlocked. Playback is
* treated as interaction through `isBusy`: a film that runs for two hours
* without a click must not lock itself half way, so an expiry that lands on
* active playback is deferred and re-checked instead of fired.
*
* Listener shape mirrors `ControlsSurface` (document-level, capture phase,
* passive) so it costs nothing per event beyond a timestamp write.
*/
const ACTIVITY_EVENTS: readonly string[] = [
'pointerdown',
'pointermove',
'keydown',
'wheel',
'touchstart',
];
/** How often a deferred expiry re-checks whether playback has stopped. */
export const PARENTAL_LOCK_BUSY_RECHECK_MS = 60_000;
export interface ParentalLockIdleTimerOptions {
onExpire: () => void;
/** True while something (playback) should keep the app unlocked. */
isBusy?: () => boolean;
/** Injectable clock and scheduler for tests. */
now?: () => number;
setTimeout?: (callback: () => void, delayMs: number) => number;
clearTimeout?: (handle: number) => void;
target?: Pick<Document, 'addEventListener' | 'removeEventListener'>;
}
export class ParentalLockIdleTimer {
private readonly onExpire: () => void;
private readonly isBusy: () => boolean;
private readonly now: () => number;
private readonly schedule: (
callback: () => void,
delayMs: number
) => number;
private readonly cancel: (handle: number) => void;
private readonly target: Pick<
Document,
'addEventListener' | 'removeEventListener'
> | null;
private timeoutMs = 0;
private lastActivityAt = 0;
private handle: number | null = null;
private listening = false;
private readonly onActivity = () => {
this.lastActivityAt = this.now();
};
constructor(options: ParentalLockIdleTimerOptions) {
this.onExpire = options.onExpire;
this.isBusy = options.isBusy ?? (() => false);
this.now = options.now ?? (() => Date.now());
this.schedule =
options.setTimeout ??
((callback, delayMs) =>
window.setTimeout(callback, delayMs) as unknown as number);
this.cancel =
options.clearTimeout ?? ((handle) => window.clearTimeout(handle));
this.target =
options.target ??
(typeof document === 'undefined' ? null : document);
}
/**
* Starts (or restarts) the countdown. `minutes === 0` means "never by
* idle" — only an explicit lock or a restart locks again.
*/
arm(minutes: number): void {
this.disarm();
if (!Number.isFinite(minutes) || minutes <= 0) {
return;
}
this.timeoutMs = minutes * 60_000;
this.lastActivityAt = this.now();
this.listen();
this.scheduleCheck(this.timeoutMs);
}
disarm(): void {
if (this.handle !== null) {
this.cancel(this.handle);
this.handle = null;
}
this.unlisten();
}
get armed(): boolean {
return this.handle !== null;
}
private scheduleCheck(delayMs: number): void {
this.handle = this.schedule(
() => {
this.handle = null;
this.check();
},
Math.max(delayMs, 1_000)
);
}
private check(): void {
const idleFor = this.now() - this.lastActivityAt;
if (idleFor < this.timeoutMs) {
this.scheduleCheck(this.timeoutMs - idleFor);
return;
}
if (this.isBusy()) {
// Playback counts as presence; look again once it may have ended.
this.scheduleCheck(PARENTAL_LOCK_BUSY_RECHECK_MS);
return;
}
this.unlisten();
this.onExpire();
}
private listen(): void {
if (this.listening || !this.target) {
return;
}
this.listening = true;
for (const type of ACTIVITY_EVENTS) {
this.target.addEventListener(type, this.onActivity, {
capture: true,
passive: true,
});
}
}
private unlisten(): void {
if (!this.listening || !this.target) {
return;
}
this.listening = false;
for (const type of ACTIVITY_EVENTS) {
this.target.removeEventListener(type, this.onActivity, {
capture: true,
});
}
}
}
@@ -0,0 +1,530 @@
import { TestBed } from '@angular/core/testing';
import { ParentalLockStore } from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { ParentalLockLockStore } from './parental-lock-lock-store.service';
import { ParentalLockStorageService } from './parental-lock-storage';
describe('ParentalLockLockStore', () => {
const stored: ParentalLockStore = {
'pl-1': {
xtream: [{ categoryType: 'live', xtreamId: 7 }],
stalker: [],
m3u: [],
},
};
let storage: { readLocks: jest.Mock; writeLocks: jest.Mock };
let setCategoryLocks: jest.Mock;
let store: ParentalLockLockStore;
beforeEach(() => {
storage = {
readLocks: jest.fn(async () => JSON.parse(JSON.stringify(stored))),
writeLocks: jest.fn(async () => true),
};
setCategoryLocks = jest.fn(async () => true);
TestBed.configureTestingModule({
providers: [
ParentalLockLockStore,
{ provide: ParentalLockStorageService, useValue: storage },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource: true },
},
{ provide: DatabaseService, useValue: { setCategoryLocks } },
],
});
store = TestBed.inject(ParentalLockLockStore);
});
it('is not readable until the initial read has settled', async () => {
let resolveRead: (locks: ParentalLockStore) => void = () => undefined;
storage.readLocks.mockReturnValue(
new Promise<ParentalLockStore>((resolve) => (resolveRead = resolve))
);
const load = store.load();
expect(store.readable()).toBe(false);
resolveRead({});
await load;
expect(store.readable()).toBe(true);
});
it('is not readable until the startup re-stamp has completed', async () => {
let resolveStamp: (ok: boolean) => void = () => undefined;
setCategoryLocks.mockImplementation(
() => new Promise<boolean>((resolve) => (resolveStamp = resolve))
);
const load = store.load();
await Promise.resolve();
await Promise.resolve();
expect(setCategoryLocks).toHaveBeenCalled();
expect(store.readable()).toBe(false);
setCategoryLocks.mockResolvedValue(true);
resolveStamp(true);
await load;
expect(store.readable()).toBe(true);
});
it('stays not readable while the startup re-stamp keeps failing', async () => {
setCategoryLocks.mockResolvedValue(false);
await store.load();
expect(store.readable()).toBe(false);
await expect(store.ensureReadable()).resolves.toBe(false);
setCategoryLocks.mockResolvedValue(true);
await expect(store.ensureReadable()).resolves.toBe(true);
expect(store.readable()).toBe(true);
});
it('builds an edit on the recovered store, never on the empty fail-closed one', async () => {
storage.readLocks.mockResolvedValueOnce(null);
await store.load();
await expect(store.setM3uLocks('pl-1', ['Adult'])).resolves.toBe(true);
expect(storage.writeLocks).toHaveBeenLastCalledWith({
'pl-1': {
xtream: [{ categoryType: 'live', xtreamId: 7 }],
stalker: [],
m3u: ['Adult'],
},
});
});
it('re-derives the index from a store recovered after a failed read', async () => {
storage.readLocks.mockResolvedValueOnce(null);
await store.load();
expect(store.readable()).toBe(false);
expect(setCategoryLocks).not.toHaveBeenCalled();
await expect(store.ensureReadable()).resolves.toBe(true);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7]);
expect(store.readable()).toBe(true);
});
it('re-derives the SQLite index from the store on load', async () => {
await store.load();
// ensureReadable awaits the reconcile that load() started.
await store.ensureReadable();
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7]);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'movies', []);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'series', []);
});
it('clears the index before the last lock leaves the store', async () => {
await store.load();
await store.ensureReadable();
setCategoryLocks.mockClear();
storage.writeLocks.mockClear();
const order: string[] = [];
setCategoryLocks.mockImplementation(async () => {
order.push('stamp');
return true;
});
storage.writeLocks.mockImplementation(async () => {
order.push('persist');
return true;
});
await expect(store.setXtreamLocks('pl-1', 'live', [])).resolves.toBe(
true
);
expect(order).toEqual(['stamp', 'persist']);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', []);
expect(storage.writeLocks).toHaveBeenLastCalledWith({});
});
it('restores the cleared index when saving the emptied store fails', async () => {
await store.load();
await store.ensureReadable();
setCategoryLocks.mockClear();
storage.writeLocks.mockResolvedValueOnce(false);
await expect(store.setXtreamLocks('pl-1', 'live', [])).resolves.toBe(
false
);
// Cleared first, then put back from the previous locks.
expect(setCategoryLocks.mock.calls).toEqual([
['pl-1', 'live', []],
['pl-1', 'live', [7]],
]);
expect(store.readable()).toBe(true);
});
it('publishes a rollback only after every type is re-stamped', async () => {
await store.load();
await store.ensureReadable();
const before = store.revision();
setCategoryLocks.mockClear();
const revisionsAtStamp: number[] = [];
let call = 0;
setCategoryLocks.mockImplementation(async () => {
revisionsAtStamp.push(store.revision());
call += 1;
// live ok, movies fails, then the rollback re-stamps succeed
return call !== 2;
});
await expect(
store.replacePlaylistLocks('pl-1', {
xtream: [{ categoryType: 'movies', xtreamId: 3 }],
stalker: [],
m3u: [],
})
).resolves.toBe(false);
expect(revisionsAtStamp.every((revision) => revision === before)).toBe(
true
);
expect(store.revision()).toBe(before + 1);
});
it('does not drop the key when clearing the index fails', async () => {
await store.load();
await store.ensureReadable();
storage.writeLocks.mockClear();
setCategoryLocks.mockResolvedValue(false);
await expect(store.setXtreamLocks('pl-1', 'live', [])).resolves.toBe(
false
);
expect(storage.writeLocks).not.toHaveBeenCalled();
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
});
it('publishes the revision only after every type is stamped', async () => {
await store.load();
await store.ensureReadable();
const before = store.revision();
let resolveStamp: (ok: boolean) => void = () => undefined;
setCategoryLocks.mockImplementation(
() => new Promise<boolean>((resolve) => (resolveStamp = resolve))
);
const writing = store.replacePlaylistLocks('pl-1', {
xtream: [{ categoryType: 'movies', xtreamId: 3 }],
stalker: [],
m3u: [],
});
for (
let i = 0;
i < 50 && storage.writeLocks.mock.calls.length === 0;
i += 1
) {
await Promise.resolve();
}
expect(storage.writeLocks).toHaveBeenCalled();
expect(store.revision()).toBe(before);
setCategoryLocks.mockResolvedValue(true);
resolveStamp(true);
await expect(writing).resolves.toBe(true);
expect(store.revision()).toBe(before + 1);
});
it('serializes overlapping edits so neither overwrites the other', async () => {
await store.load();
await store.ensureReadable();
let releaseFirst: (ok: boolean) => void = () => undefined;
storage.writeLocks.mockImplementationOnce(
() => new Promise<boolean>((resolve) => (releaseFirst = resolve))
);
const first = store.setM3uLocks('pl-1', ['Adult']);
const second = store.setStalkerLocks('pl-1', 'itv', ['9']);
for (let i = 0; i < 20; i += 1) {
await Promise.resolve();
}
expect(storage.writeLocks).toHaveBeenCalledTimes(1);
releaseFirst(true);
await expect(first).resolves.toBe(true);
await expect(second).resolves.toBe(true);
expect(storage.writeLocks).toHaveBeenLastCalledWith({
'pl-1': {
xtream: [{ categoryType: 'live', xtreamId: 7 }],
stalker: [{ categoryType: 'itv', categoryId: '9' }],
m3u: ['Adult'],
},
});
});
it("applies back-to-back single-row edits to each other's result", async () => {
await store.load();
await store.ensureReadable();
let releaseFirst: (ok: boolean) => void = () => undefined;
storage.writeLocks.mockImplementationOnce(
() => new Promise<boolean>((resolve) => (releaseFirst = resolve))
);
const first = store.setStalkerLocks('pl-1', 'itv', (current) => [
...current,
'1',
]);
const second = store.setStalkerLocks('pl-1', 'itv', (current) => [
...current,
'2',
]);
for (
let i = 0;
i < 50 && storage.writeLocks.mock.calls.length === 0;
i += 1
) {
await Promise.resolve();
}
releaseFirst(true);
await Promise.all([first, second]);
expect(store.lockedStalkerIds('pl-1', 'itv')).toEqual(['1', '2']);
});
it('drops a deleted playlist from the store and empties it on clearAll', async () => {
await store.load();
await store.ensureReadable();
setCategoryLocks.mockClear();
await expect(store.removePlaylist('missing')).resolves.toBe(true);
await expect(store.removePlaylist('pl-1')).resolves.toBe(true);
expect(storage.writeLocks).toHaveBeenLastCalledWith({});
expect(setCategoryLocks).not.toHaveBeenCalled();
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([]);
await expect(store.clearAll()).resolves.toBe(true);
expect(storage.writeLocks).toHaveBeenLastCalledWith({});
});
it('empties the store now and retries a failed persisted clear', async () => {
await store.load();
await store.ensureReadable();
storage.writeLocks.mockResolvedValueOnce(false);
await expect(store.clearAll()).resolves.toBe(false);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([]);
storage.writeLocks.mockClear();
await expect(store.ensureReadable()).resolves.toBe(true);
expect(storage.writeLocks).toHaveBeenCalledWith({});
});
it('is not readable while a write is re-stamping the index', async () => {
await store.load();
await store.ensureReadable();
const readableAtStamp: boolean[] = [];
setCategoryLocks.mockImplementation(async () => {
readableAtStamp.push(store.readable());
return true;
});
await expect(
store.setXtreamLocks('pl-1', 'live', [7, 9])
).resolves.toBe(true);
await expect(store.setXtreamLocks('pl-1', 'live', [])).resolves.toBe(
true
);
expect(readableAtStamp.length).toBeGreaterThan(0);
expect(readableAtStamp.every((readable) => !readable)).toBe(true);
expect(store.readable()).toBe(true);
});
it('rolls the store back when the index re-stamp fails', async () => {
await store.load();
setCategoryLocks.mockResolvedValue(false);
await expect(
store.setXtreamLocks('pl-1', 'live', [7, 9])
).resolves.toBe(false);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
expect(storage.writeLocks).toHaveBeenLastCalledWith(
expect.objectContaining({
'pl-1': expect.objectContaining({
xtream: [{ categoryType: 'live', xtreamId: 7 }],
}),
})
);
});
it('re-stamps every touched type from the restored store after a partial multi-type failure', async () => {
await store.load();
await store.ensureReadable();
setCategoryLocks.mockClear();
// live commits, movies fails: the index now carries the NEW live
// locks while the store is rolled back to the old ones.
setCategoryLocks
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValue(true);
await expect(
store.replacePlaylistLocks('pl-1', {
xtream: [{ categoryType: 'movies', xtreamId: 3 }],
stalker: [],
m3u: [],
})
).resolves.toBe(false);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
// Rollback re-stamps all three types from the restored store.
expect(setCategoryLocks.mock.calls.slice(3)).toEqual([
['pl-1', 'live', [7]],
['pl-1', 'movies', []],
['pl-1', 'series', []],
]);
expect(store.readable()).toBe(true);
});
it('restores the previous locks on the next access when even the rollback write failed', async () => {
await store.load();
await store.ensureReadable();
jest.spyOn(console, 'error').mockImplementation(() => undefined);
setCategoryLocks.mockClear();
setCategoryLocks.mockResolvedValueOnce(false);
storage.writeLocks
.mockResolvedValueOnce(true) // the new locks
.mockResolvedValueOnce(false); // the rollback
await expect(
store.setXtreamLocks('pl-1', 'live', [7, 9])
).resolves.toBe(false);
// The failed edit does not take effect: memory holds the previous
// locks and the store stays fail-closed until they are persisted.
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
expect(store.readable()).toBe(false);
await expect(store.ensureReadable()).resolves.toBe(true);
expect(storage.writeLocks).toHaveBeenLastCalledWith(
expect.objectContaining({
'pl-1': expect.objectContaining({
xtream: [{ categoryType: 'live', xtreamId: 7 }],
}),
})
);
// Re-stamped from the restored locks, not the failed edit.
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7]);
expect(store.readable()).toBe(true);
});
it('stays not readable until the store write of a cleared last lock lands', async () => {
await store.load();
await store.ensureReadable();
let finishWrite: (ok: boolean) => void = () => undefined;
storage.writeLocks.mockImplementationOnce(
() => new Promise<boolean>((resolve) => (finishWrite = resolve))
);
const revision = store.revision();
const clearing = store.setXtreamLocks('pl-1', 'live', []);
await waitFor(() => storage.writeLocks.mock.calls.length > 0);
// The index is already cleared; the store still holds the lock.
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', []);
expect(store.readable()).toBe(false);
expect(store.revision()).toBe(revision);
finishWrite(false);
await expect(clearing).resolves.toBe(false);
// Restored from the store, which kept the lock.
expect(setCategoryLocks).toHaveBeenLastCalledWith('pl-1', 'live', [7]);
expect(store.readable()).toBe(true);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
});
it('refuses, at commit time, an edit that removes a lock while removal is gated', async () => {
await store.load();
await store.ensureReadable();
let unlocked = true;
store.setRemovalGate(() => unlocked);
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
// An earlier write holds the queue while the app relocks: the edit
// queued behind it was built unlocked but commits locked.
let finishFirst: (ok: boolean) => void = () => undefined;
storage.writeLocks.mockImplementationOnce(
() => new Promise<boolean>((resolve) => (finishFirst = resolve))
);
const adding = store.setM3uLocks('pl-1', ['Adults']);
const removing = store.setXtreamLocks('pl-1', 'live', []);
await waitFor(() => storage.writeLocks.mock.calls.length > 0);
unlocked = false;
finishFirst(true);
await expect(adding).resolves.toBe(true);
await expect(removing).resolves.toBe(false);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
// Adding stays allowed while locked; removing again once unlocked.
await expect(
store.setXtreamLocks('pl-1', 'live', [7, 9])
).resolves.toBe(true);
await expect(
store.replacePlaylistLocks('pl-1', {
xtream: [],
stalker: [],
m3u: [],
})
).resolves.toBe(false);
unlocked = true;
await expect(store.setM3uLocks('pl-1', [])).resolves.toBe(true);
expect(store.lockedGroupTitles('pl-1')).toEqual([]);
});
it('completes a removal whose write was issued before the app relocked', async () => {
// The parent authorized the removal; a relock that lands once its
// write is issued is ordered after it (no post-write rollback, which
// could itself fail and leave the persisted store diverged).
storage.readLocks.mockResolvedValue({
'pl-1': { xtream: [], stalker: [], m3u: ['Adults'] },
});
await store.load();
await store.ensureReadable();
let unlocked = true;
store.setRemovalGate(() => unlocked);
storage.writeLocks.mockImplementationOnce(async () => {
unlocked = false;
return true;
});
await expect(store.setM3uLocks('pl-1', [])).resolves.toBe(true);
expect(store.lockedGroupTitles('pl-1')).toEqual([]);
expect(storage.writeLocks).toHaveBeenCalledTimes(1);
expect(store.readable()).toBe(true);
});
it('does not re-stamp an in-flight write from the uncommitted store', async () => {
await store.load();
await store.ensureReadable();
setCategoryLocks.mockClear();
let finishWrite: (ok: boolean) => void = () => undefined;
storage.writeLocks.mockImplementationOnce(
() => new Promise<boolean>((resolve) => (finishWrite = resolve))
);
const clearing = store.setXtreamLocks('pl-1', 'live', []);
await waitFor(() => storage.writeLocks.mock.calls.length > 0);
// A PIN prompt or backup reaches ensureReadable() beside the write.
await expect(store.ensureReadable()).resolves.toBe(false);
finishWrite(true);
await expect(clearing).resolves.toBe(true);
// Only the write's own clear: memory still held [7] while it was in
// flight, and a reconcile stamping that would have re-locked rows
// the store no longer lists.
expect(setCategoryLocks.mock.calls).toEqual([['pl-1', 'live', []]]);
expect(store.readable()).toBe(true);
});
});
async function waitFor(condition: () => boolean): Promise<void> {
for (let i = 0; i < 50 && !condition(); i++) {
await Promise.resolve();
}
expect(condition()).toBe(true);
}
@@ -0,0 +1,546 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import {
createEmptyParentalLockPlaylistLocks,
isParentalLockPlaylistLocksEmpty,
lockedStalkerCategoryIds,
lockedXtreamCategoryIds,
normalizeParentalLockPlaylistLocks,
ParentalLockPlaylistLocks,
ParentalLockStalkerCategoryType,
ParentalLockStore,
ParentalLockXtreamCategoryType,
} from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { ParentalLockStaleIndex } from './parental-lock-stale-index';
import { ParentalLockStorageService } from './parental-lock-storage';
import {
lockRemovalGate,
stampXtreamIndex,
withM3uLocks,
withPlaylistLocksInStore,
withStalkerLocks,
withXtreamLocks,
} from './parental-lock-store.util';
/**
* A full replacement list, or an edit of the CURRENT list. The edit form is
* evaluated inside the write queue, so single-row toggles issued back to
* back each see the previous toggle's result instead of one shared snapshot.
*/
export type LockListEdit<T> = readonly T[] | ((current: readonly T[]) => T[]);
function applyLockListEdit<T>(
edit: LockListEdit<T>,
current: readonly T[]
): T[] {
return typeof edit === 'function' ? edit(current) : [...edit];
}
const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [
'live',
'movies',
'series',
];
/**
* The lock store: WHICH categories are locked, per playlist, persisted
* through `ParentalLockStorageService`. Knows nothing about the PIN or the
* session state — `ParentalLockService` composes it and adds `active`.
*
* A store that could not be READ is a distinct state (`unreadable`): it is
* not "nothing is locked", and no write may be built on the empty in-memory
* store while it lasts, or the persisted locks would be wiped.
*/
@Injectable({ providedIn: 'root' })
export class ParentalLockLockStore {
private readonly storage = inject(ParentalLockStorageService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly databaseService = inject(DatabaseService);
private readonly locks = signal<ParentalLockStore>({});
private readonly revisionState = signal(0);
private readonly loadedState = signal(false);
private loading: Promise<void> | null = null;
/**
* Whether an edit may REMOVE a lock right now; set by
* `ParentalLockService` to "the session is not locked". Asked inside
* the write queue right before the edit's first write: an editor opened
* while unlocked may still be saving (or queued) when the app relocks.
* A relock that lands once the write is issued is ordered after it: the
* parent authorized that removal, so it completes.
*/
private mayRemoveLocks: () => boolean = () => true;
/**
* Mutations run one at a time: each rewrites the WHOLE persisted store
* from the in-memory copy, so two overlapping edits would snapshot the
* same store and the later write would silently drop the earlier edit.
*/
private writeQueue: Promise<unknown> = Promise.resolve();
/**
* The persisted store differs from the in-memory one and must be
* rewritten from it: "Remove all playlists" could not clear it, or a
* failed edit could not be rolled back. Retried on the next store
* access (before the index is re-stamped from memory); the store is not
* `readable` meanwhile.
*/
private readonly pendingRewrite = signal(false);
private readonly staleIndex = new ParentalLockStaleIndex();
/** The persisted store could not be read; see `ensureReadable()`. */
readonly unreadable = signal(false);
/**
* The store has been read, is trustworthy, and (on Electron) the SQLite
* index agrees with it. False while the initial read is still in flight
* — the settings can report the feature as on before the locks are
* known, and an empty in-memory store must not read as "nothing is
* locked" in that window — and while a re-stamp is outstanding, since
* Electron reads filter by the index alone.
*/
readonly readable = computed(
() =>
this.loadedState() &&
!this.unreadable() &&
!this.pendingRewrite() &&
this.staleIndex.isEmpty()
);
/** Bumps whenever the lock set changes; consumers re-query. */
readonly revision = this.revisionState.asReadonly();
readonly isEmpty = computed(() => Object.keys(this.locks()).length === 0);
/**
* Reads the persisted store once. On Electron the `categories.locked`
* index is then re-derived from it for every playlist that has locks:
* the store is authoritative, and a re-stamp that failed in an earlier
* session (or a database restored beside a newer store) must not leave
* the index behind indefinitely.
*/
load(): Promise<void> {
if (!this.loading) {
this.loading = (async () => {
const locks = await this.storage.readLocks();
if (locks === null) {
console.error('The parental lock store could not be read.');
this.unreadable.set(true);
} else {
this.locks.set(locks);
for (const playlistId of Object.keys(locks)) {
this.staleIndex.mark(playlistId);
}
// Awaited: catalog reads issued before the index agrees
// with the store would serve rows stamped unlocked, and
// nothing would reload them afterwards.
await this.reconcileXtreamIndex();
}
this.loadedState.set(true);
this.revisionState.update((value) => value + 1);
})();
}
return this.loading;
}
/**
* Re-reads a store that failed to read earlier. False while it still
* cannot be read.
*/
async ensureReadable(): Promise<boolean> {
await this.load();
if (this.pendingRewrite() && !(await this.rewritePersistedStore())) {
return false;
}
if (this.unreadable()) {
const locks = await this.storage.readLocks();
if (locks === null) {
return false;
}
this.locks.set(locks);
this.unreadable.set(false);
// The index may carry stamps from a write that failed before
// the read did; re-derive it from the recovered store as load()
// does.
for (const playlistId of Object.keys(locks)) {
this.staleIndex.mark(playlistId);
}
this.revisionState.update((value) => value + 1);
}
await this.reconcileXtreamIndex();
return this.readable();
}
/** Re-stamps every playlist whose index may lag behind the store. */
private async reconcileXtreamIndex(): Promise<void> {
if (!this.runtime.supportsXtreamSqliteDataSource) {
this.staleIndex.clear();
return;
}
if (
await this.staleIndex.reconcile((id) => this.stampXtreamLocks(id))
) {
this.revisionState.update((value) => value + 1);
}
}
setRemovalGate(gate: () => boolean): void {
this.mayRemoveLocks = gate;
}
locksFor(playlistId: string): ParentalLockPlaylistLocks {
return (
this.locks()[playlistId] ?? createEmptyParentalLockPlaylistLocks()
);
}
lockedXtreamIds(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType
): number[] {
return lockedXtreamCategoryIds(this.locks()[playlistId], categoryType);
}
lockedStalkerIds(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType
): string[] {
return lockedStalkerCategoryIds(this.locks()[playlistId], categoryType);
}
lockedGroupTitles(playlistId: string): string[] {
return this.locks()[playlistId]?.m3u ?? [];
}
/**
* Every mutation re-reads a store that failed to read BEFORE building
* the edit: `locksFor` on the empty fail-closed store would otherwise
* turn one type's edit into the playlist's entire lock set once the
* write goes through, discarding its other locks.
*/
async setXtreamLocks(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType,
xtreamIds: LockListEdit<number>
): Promise<boolean> {
return this.enqueue(async () => {
if (!(await this.ensureReadable())) {
return false;
}
const previous = this.locksFor(playlistId);
const next = withXtreamLocks(
previous,
categoryType,
applyLockListEdit(
xtreamIds,
this.lockedXtreamIds(playlistId, categoryType)
)
);
return this.commitLocks(playlistId, previous, next, [categoryType]);
});
}
async setStalkerLocks(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType,
categoryIds: LockListEdit<string>
): Promise<boolean> {
return this.enqueue(async () => {
if (!(await this.ensureReadable())) {
return false;
}
const previous = this.locksFor(playlistId);
const next = withStalkerLocks(
previous,
categoryType,
applyLockListEdit(
categoryIds,
this.lockedStalkerIds(playlistId, categoryType)
)
);
return this.persistEdit(playlistId, previous, next);
});
}
async setM3uLocks(
playlistId: string,
groupTitles: LockListEdit<string>
): Promise<boolean> {
return this.enqueue(async () => {
if (!(await this.ensureReadable())) {
return false;
}
const previous = this.locksFor(playlistId);
const next = withM3uLocks(
previous,
applyLockListEdit(
groupTitles,
this.lockedGroupTitles(playlistId)
)
);
return this.persistEdit(playlistId, previous, next);
});
}
/** Backup restore: replaces every lock of one playlist. */
async replacePlaylistLocks(
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
return this.enqueue(async () => {
if (!(await this.ensureReadable())) {
return false;
}
return this.commitLocks(
playlistId,
this.locksFor(playlistId),
locks,
XTREAM_CATEGORY_TYPES
);
});
}
/**
* A deleted playlist's locks leave the store. No re-stamp: its category
* rows are deleted with it. True when there was nothing to remove.
*/
removePlaylist(playlistId: string): Promise<boolean> {
return this.enqueue(async () => {
if (!(await this.ensureReadable())) {
return false;
}
if (!this.locks()[playlistId]) {
return true;
}
return this.persistPlaylistLocks(
playlistId,
createEmptyParentalLockPlaylistLocks()
);
});
}
/** "Remove all playlists": every lock goes with them. */
clearAll(): Promise<boolean> {
return this.enqueue(async () => {
await this.load();
// The playlists are gone either way: the in-memory store empties
// now, and a failed persisted clear is retried on the next
// store access (`ensureReadable`) and by the next write, which
// rewrites the whole store from this empty copy.
this.locks.set({});
this.unreadable.set(false);
this.staleIndex.clear();
this.revisionState.update((value) => value + 1);
return this.rewritePersistedStore();
});
}
/** Stalker and M3U edits: no index, only the store write. */
private async persistEdit(
playlistId: string,
previous: ParentalLockPlaylistLocks,
next: ParentalLockPlaylistLocks
): Promise<boolean> {
return (
lockRemovalGate(previous, next, this.mayRemoveLocks)() &&
this.persistPlaylistLocks(playlistId, next)
);
}
/** Writes the in-memory store; a failure leaves `pendingRewrite` set. */
private async rewritePersistedStore(): Promise<boolean> {
const written = await this.storage.writeLocks(this.locks());
if (!written) {
console.error('The parental lock store could not be rewritten.');
}
if (written === this.pendingRewrite()) {
this.pendingRewrite.set(!written);
this.revisionState.update((value) => value + 1);
}
return written;
}
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const run = this.writeQueue.then(task, task);
this.writeQueue = run.catch(() => undefined);
return run;
}
/**
* Persists `next` and re-stamps the touched types. Order matters for a
* playlist whose LAST lock goes away: its key leaves the store, and the
* startup reconcile finds playlists only through their key — a crash
* between the two writes would strand stamped rows for good. That case
* clears the index first (from `next`) and persists afterwards, so an
* interruption leaves the store with the lock and the index without it,
* which the next reconcile repairs toward locked. Every other write
* persists first and rolls back on a failed re-stamp.
*/
private async commitLocks(
playlistId: string,
previous: ParentalLockPlaylistLocks,
next: ParentalLockPlaylistLocks,
categoryTypes: readonly ParentalLockXtreamCategoryType[]
): Promise<boolean> {
const authorized = lockRemovalGate(previous, next, this.mayRemoveLocks);
const normalizedNext = normalizeParentalLockPlaylistLocks(next);
if (isParentalLockPlaylistLocksEmpty(normalizedNext)) {
if (!(await this.ensureReadable()) || !authorized()) {
return false;
}
// Stale until the STORE write has landed too: while it is
// pending the index is already cleared but the store still
// holds the lock, and a relock in that window must not reload
// through the cleared index.
this.markStaleWhileStamping(playlistId);
if (
(await this.stampXtreamLocks(
playlistId,
categoryTypes,
normalizedNext
)) &&
(await this.persistPlaylistLocks(playlistId, normalizedNext, {
publish: false,
}))
) {
this.staleIndex.unmark(playlistId);
this.revisionState.update((value) => value + 1);
return true;
}
// The clear (partly) reached the index but the store still holds
// the locks: put the index back at once — consumers that query
// the worker directly (title matching, multi-source discovery)
// trust `locked` and are not gated by the renderer's stale flag.
await this.restoreIndex(playlistId, previous, categoryTypes);
return false;
}
// The revision is published only once every type is stamped: a
// consumer reloading on the revision while the stamps are still
// running would read a later type through its old stamps, and a
// successful stamp emits nothing afterwards to reload it.
if (
!authorized() ||
!(await this.persistPlaylistLocks(playlistId, next, {
publish: false,
}))
) {
return false;
}
this.markStaleWhileStamping(playlistId);
if (await this.stampXtreamLocks(playlistId, categoryTypes)) {
this.staleIndex.unmark(playlistId);
this.revisionState.update((value) => value + 1);
return true;
}
await this.rollBack(playlistId, previous, categoryTypes);
return false;
}
/**
* The store commits before the SQLite index is re-stamped; a failed
* re-stamp would otherwise leave a category recorded (and shown) as
* locked while Electron reads, which filter by the index alone, still
* serve it. The store goes back to the previous locks and the touched
* types are re-stamped from it — a multi-type re-stamp may have
* committed some types before the failing one. If either step fails,
* the playlist is marked stale, which keeps the session fail-closed and
* re-stamps it on the next access.
*/
private async rollBack(
playlistId: string,
previous: ParentalLockPlaylistLocks,
categoryTypes: readonly ParentalLockXtreamCategoryType[]
): Promise<void> {
// Published once, after the re-stamp: a reload on an earlier
// revision would read a later type through the attempted stamps.
const restored = await this.persistPlaylistLocks(playlistId, previous, {
publish: false,
});
if (!restored) {
// Memory goes back to the previous locks anyway, and the
// persisted copy is rewritten from it on the next access: the
// failed edit must not take effect through a later re-stamp.
this.locks.set(
withPlaylistLocksInStore(this.locks(), playlistId, previous)
);
this.pendingRewrite.set(true);
}
const restamped =
restored &&
(await this.stampXtreamLocks(playlistId, categoryTypes));
if (!restored || !restamped) {
console.error('Failed to roll back the parental lock index.');
this.staleIndex.mark(playlistId);
} else {
this.staleIndex.unmark(playlistId);
}
this.revisionState.update((value) => value + 1);
}
/**
* From the store change until every stamp has landed the SQLite index
* does not agree with the store: `readable` must be false for that whole
* window, or a relock inside it would reload through the old stamps.
* Only Electron has the index.
*/
private markStaleWhileStamping(playlistId: string): void {
if (this.runtime.supportsXtreamSqliteDataSource) {
this.staleIndex.markInFlight(playlistId);
}
}
/** Re-stamps the index from `locks`; marks it stale when that fails. */
private async restoreIndex(
playlistId: string,
locks: ParentalLockPlaylistLocks,
categoryTypes: readonly ParentalLockXtreamCategoryType[]
): Promise<void> {
if (!(await this.stampXtreamLocks(playlistId, categoryTypes, locks))) {
console.error('Failed to restore the parental lock index.');
this.staleIndex.mark(playlistId);
} else {
this.staleIndex.unmark(playlistId);
}
this.revisionState.update((value) => value + 1);
}
/**
* Re-stamps the Electron `categories.locked` index for a playlist from
* the store, e.g. after a refresh recreated the rows.
*/
async stampXtreamLocks(
playlistId: string,
categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES,
locks: ParentalLockPlaylistLocks = this.locksFor(playlistId)
): Promise<boolean> {
return (
!this.runtime.supportsXtreamSqliteDataSource ||
stampXtreamIndex(
(id, type, ids) =>
this.databaseService.setCategoryLocks(id, type, ids),
playlistId,
categoryTypes,
locks
)
);
}
private async persistPlaylistLocks(
playlistId: string,
locks: ParentalLockPlaylistLocks,
options: { publish?: boolean } = {}
): Promise<boolean> {
// Every public mutation runs `ensureReadable()` before it builds its
// edit. Here only the STORE must be known: the index of the
// playlist being written is deliberately stale while it is
// re-stamped, and a rollback must still be able to write.
if (this.unreadable()) {
return false;
}
const next = withPlaylistLocksInStore(this.locks(), playlistId, locks);
if (!(await this.storage.writeLocks(next))) {
return false;
}
this.locks.set(next);
if (options.publish !== false) {
this.revisionState.update((value) => value + 1);
}
return true;
}
}
@@ -0,0 +1,27 @@
import { inject, Provider } from '@angular/core';
import { PLAYLIST_DELETE_CLEANUP } from '../playlist-delete-cleanup.token';
import { ParentalLockService } from './parental-lock.service';
/**
* Removes a deleted playlist's locks from the persisted lock store. Every
* single-playlist deletion runs `PlaylistsService.deletePlaylist`, which
* calls the `PLAYLIST_DELETE_CLEANUP` hooks after the row is gone; "remove
* all playlists" clears the store through `ParentalLockService.clearAllLocks`
* instead.
*/
export function provideParentalLockPlaylistCleanup(): Provider {
return {
provide: PLAYLIST_DELETE_CLEANUP,
multi: true,
useFactory: () => {
const parentalLock = inject(ParentalLockService);
return async (playlistId: string) => {
if (!(await parentalLock.removePlaylistLocks(playlistId))) {
throw new Error(
'The parental locks of the deleted playlist could not be removed.'
);
}
};
},
};
}
@@ -0,0 +1,38 @@
import { InjectionToken } from '@angular/core';
import type { ParentalLockPinThrottle } from '@iptvnator/shared/interfaces';
export type ParentalLockPromptMode = 'unlock' | 'set';
export interface ParentalLockPromptRequest {
/** `unlock` asks for the current PIN, `set` asks for a new one twice. */
mode: ParentalLockPromptMode;
/**
* Unlock only: resolves whether the typed PIN matches. The prompt keeps
* asking on a mismatch and applies its own attempt cooldown.
*/
verify?: (pin: string) => Promise<boolean>;
/**
* Unlock only: the wrong-PIN count and cooldown, owned by the lock
* service so they survive the prompt being dismissed and reopened.
*/
throttle?: ParentalLockPinThrottle;
/** Optional translation key overriding the mode's default title. */
titleKey?: string;
/** Optional translation key overriding the mode's default description. */
descriptionKey?: string;
}
/**
* The UI half of the parental lock: shows a PIN prompt and resolves with the
* accepted PIN, or `null` when the user dismissed it.
*
* Provided by the application (which may depend on UI libraries) so the
* lock service in this data-access library stays free of dialogs.
*/
export interface ParentalLockPrompt {
requestPin(request: ParentalLockPromptRequest): Promise<string | null>;
}
export const PARENTAL_LOCK_PROMPT = new InjectionToken<ParentalLockPrompt>(
'PARENTAL_LOCK_PROMPT'
);
@@ -0,0 +1,51 @@
import { persistParentalLockEnabled } from './parental-lock-settings-writer';
describe('persistParentalLockEnabled', () => {
it('rolls a failed write back to the value recovered by the settings retry', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
let failure: 'load' | 'save' | null = 'load';
let stored: boolean | undefined = undefined;
const settings = {
storageFailure: () => failure,
parentalLockEnabled: () => stored,
// The retried read recovers: the switch was already off.
loadSettings: jest.fn(async () => {
failure = null;
stored = false;
}),
updateSettings: jest
.fn()
.mockRejectedValueOnce(new Error('disk full'))
.mockResolvedValue(undefined),
};
await expect(persistParentalLockEnabled(settings, false)).resolves.toBe(
false
);
// Undone to the recovered `false`, not the hard-coded inverse.
expect(settings.updateSettings).toHaveBeenLastCalledWith({
parentalLockEnabled: false,
});
});
it('undoes to the inverse when the switch was never persisted', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
const settings = {
storageFailure: () => null,
loadSettings: jest.fn(),
updateSettings: jest
.fn()
.mockRejectedValueOnce(new Error('disk full'))
.mockResolvedValue(undefined),
};
await expect(persistParentalLockEnabled(settings, true)).resolves.toBe(
false
);
expect(settings.updateSettings).toHaveBeenLastCalledWith({
parentalLockEnabled: false,
});
});
});
@@ -0,0 +1,99 @@
import { normalizeParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
import { SettingsStore } from '../settings-store.service';
import { mirrorParentalLockEnabledSetting } from './parental-lock-bridge';
type SettingsWriter = Pick<
InstanceType<typeof SettingsStore>,
'updateSettings' | 'loadSettings'
> & {
storageFailure?: () => 'load' | 'save' | null;
parentalLockEnabled?: () => boolean | undefined;
};
/**
* `updateSettings` writes the WHOLE settings object. After a failed startup
* read that object is the defaults, so a parental-lock write would replace
* the user's persisted player, portal and UI preferences. The read is
* retried first (a transient failure recovers here), and the write is
* refused while settings stay unreadable.
*/
export async function ensureParentalLockSettingsReadable(
settings: SettingsWriter
): Promise<boolean> {
if (settings.storageFailure?.() !== 'load') {
return true;
}
await settings.loadSettings();
return settings.storageFailure?.() !== 'load';
}
/**
* Persists the feature switch. `updateSettings` patches the in-memory value
* before the write; on a failed write that patch is undone (the second write
* fails the same way and is ignored), so the toggle cannot show a state the
* next launch will not have. The Electron mirror is written after the
* settings and undoes them the same way when it fails: a reloaded renderer
* and a restarted worker start from the mirror.
*/
export async function persistParentalLockEnabled(
settings: SettingsWriter,
enabled: boolean
): Promise<boolean> {
if (!(await ensureParentalLockSettingsReadable(settings))) {
return false;
}
// Read AFTER the retry, as for the relock timeout: a recovered read may
// already hold `enabled` (e.g. disable() entered while the unknown
// switch followed the stored PIN), and undoing to the inverse would
// then write the opposite of what was persisted.
const previous = settings.parentalLockEnabled?.() ?? !enabled;
const undo = () =>
settings
.updateSettings({ parentalLockEnabled: previous })
.catch(() => undefined);
try {
await settings.updateSettings({ parentalLockEnabled: enabled });
} catch (error) {
console.error('Failed to persist the parental lock switch.', error);
await undo();
return false;
}
if (!(await mirrorParentalLockEnabledSetting(enabled))) {
await undo();
return false;
}
return true;
}
/**
* False when the value could not be persisted; the in-memory patch is undone
* so the timer on screen never differs from the one the next launch uses.
*/
export async function persistParentalLockRelockMinutes(
settings: SettingsWriter,
minutes: number,
readCurrent: () => number
): Promise<boolean> {
if (!(await ensureParentalLockSettingsReadable(settings))) {
return false;
}
// Read AFTER the retry: a recovered read replaces the defaults, and a
// rollback to the pre-retry value would write the default back.
const previous = readCurrent();
try {
await settings.updateSettings({
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(minutes),
});
return true;
} catch (error) {
console.error('Failed to persist the relock timeout.', error);
await settings
.updateSettings({
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(previous),
})
.catch(() => undefined);
return false;
}
}
@@ -0,0 +1,77 @@
import { computed, signal } from '@angular/core';
/**
* Playlists whose SQLite `categories.locked` index may lag behind the lock
* store: a re-stamp in progress, or one that failed (with its rollback) and
* is retried on the next store access. The lock store is not `readable`
* while any playlist is listed.
*/
export class ParentalLockStaleIndex {
private readonly playlists = new Set<string>();
/**
* Listed while a queued write is stamping them: stale (not `readable`)
* but not retryable — a reconcile running beside the write would
* re-stamp from a store the write has not committed yet.
*/
private readonly inFlight = new Set<string>();
private readonly count = signal(0);
readonly isEmpty = computed(() => this.count() === 0);
/** The retryable entries: not the ones a write is still stamping. */
ids(): string[] {
return [...this.playlists].filter((id) => !this.inFlight.has(id));
}
/** Stale and retryable (a failed stamp or rollback). */
mark(playlistId: string): void {
this.inFlight.delete(playlistId);
this.playlists.add(playlistId);
this.count.set(this.playlists.size);
}
/** Stale while the write in progress stamps it. */
markInFlight(playlistId: string): void {
this.inFlight.add(playlistId);
this.playlists.add(playlistId);
this.count.set(this.playlists.size);
}
unmark(...playlistIds: string[]): void {
for (const playlistId of playlistIds) {
this.playlists.delete(playlistId);
this.inFlight.delete(playlistId);
}
this.count.set(this.playlists.size);
}
clear(): void {
this.unmark(...this.playlists);
}
/**
* Re-stamps every listed playlist through `stamp`; the ones that
* succeed leave the list. True when any did.
*/
async reconcile(
stamp: (playlistId: string) => Promise<boolean>
): Promise<boolean> {
const restamped: string[] = [];
for (const playlistId of this.ids()) {
try {
if (await stamp(playlistId)) {
restamped.push(playlistId);
}
} catch (error) {
console.error(
'Failed to reconcile the parental lock index.',
error
);
}
}
if (restamped.length > 0) {
this.unmark(...restamped);
}
return restamped.length > 0;
}
}
@@ -0,0 +1,90 @@
import { TestBed } from '@angular/core/testing';
import { PARENTAL_LOCK_STORE_KEY } from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { ParentalLockStorageService } from './parental-lock-storage';
describe('ParentalLockStorageService.readLocks', () => {
const runtime = { supportsAppStateStorage: false };
const databaseService = {
readAppState: jest.fn<
Promise<{ value: string | null } | null>,
[string]
>(),
};
let service: ParentalLockStorageService;
beforeEach(() => {
localStorage.clear();
runtime.supportsAppStateStorage = false;
databaseService.readAppState.mockReset();
TestBed.configureTestingModule({
providers: [
ParentalLockStorageService,
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{ provide: DatabaseService, useValue: databaseService },
],
});
service = TestBed.inject(ParentalLockStorageService);
});
it('reads an absent store as empty', async () => {
await expect(service.readLocks()).resolves.toEqual({});
});
it('normalizes a stored store', async () => {
localStorage.setItem(
PARENTAL_LOCK_STORE_KEY,
JSON.stringify({
p: { xtream: [], stalker: [], m3u: ['Adult'], junk: 1 },
})
);
await expect(service.readLocks()).resolves.toEqual({
p: { xtream: [], stalker: [], m3u: ['Adult'] },
});
});
it.each([
['truncated JSON', '{"p":{"m3u":["Adu'],
['an array payload', '[]'],
['a scalar payload', '"locks"'],
[
'a corrupt nested list',
'{"p":{"xtream":"corrupt","stalker":[],"m3u":[]}}',
],
[
'a corrupt nested entry',
'{"p":{"xtream":[],"stalker":[],"m3u":[1]}}',
],
['a playlist entry missing a list', '{"p":{}}'],
[
'an entry the normalizer would drop',
'{"p":{"xtream":[{"categoryType":"live","xtreamId":"nope"}],"stalker":[],"m3u":[]}}',
],
])('treats %s as a failed read, not an empty store', async (_, raw) => {
localStorage.setItem(PARENTAL_LOCK_STORE_KEY, raw);
await expect(service.readLocks()).resolves.toBeNull();
});
it('treats a throwing localStorage as a failed read', async () => {
const getItem = jest
.spyOn(Storage.prototype, 'getItem')
.mockImplementation(() => {
throw new Error('SecurityError');
});
try {
await expect(service.readLocks()).resolves.toBeNull();
} finally {
getItem.mockRestore();
}
});
it('keeps an Electron read failure apart from a missing key', async () => {
runtime.supportsAppStateStorage = true;
databaseService.readAppState.mockResolvedValueOnce(null);
await expect(service.readLocks()).resolves.toBeNull();
databaseService.readAppState.mockResolvedValueOnce({ value: null });
await expect(service.readLocks()).resolves.toEqual({});
});
});
@@ -0,0 +1,104 @@
import { inject, Injectable } from '@angular/core';
import {
isWellFormedParentalLockStore,
normalizeParentalLockStore,
PARENTAL_LOCK_PIN_KEY,
PARENTAL_LOCK_STORE_KEY,
ParentalLockStore,
} from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
/**
* Persistence for the parental lock: the hashed PIN and the lock store.
*
* Both live OUTSIDE `Settings` on purpose. Settings are logged (redacted,
* but still), exported in backups and mirrored to the main process; a PIN
* hash belongs in none of those places. Electron keeps them in the SQLite
* `app_state` table, the PWA in localStorage.
*/
@Injectable({ providedIn: 'root' })
export class ParentalLockStorageService {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly databaseService = inject(DatabaseService);
private get usesAppState(): boolean {
return this.runtime.supportsAppStateStorage;
}
/**
* The stored PIN hash (`hash: null` when none was set up), or `null`
* when the read itself failed — the service then retries before every
* PIN-protected operation instead of treating the PIN as absent.
*/
async readPinHash(): Promise<{ hash: string | null } | null> {
const result = await this.read(PARENTAL_LOCK_PIN_KEY);
if (result === null) {
return null;
}
const value = result.value;
return { hash: value && value.trim() !== '' ? value : null };
}
async writePinHash(hash: string): Promise<boolean> {
return this.write(PARENTAL_LOCK_PIN_KEY, hash);
}
/**
* The lock store, or `null` when it could not be READ — distinct from an
* absent store, which is `{}`. A failed read must not look like "nothing
* is locked": the service withholds everything until it can read again.
* A stored payload that does not parse, or is not an object, counts as
* a failed read too: corruption must not become an empty store.
*/
async readLocks(): Promise<ParentalLockStore | null> {
const result = await this.read(PARENTAL_LOCK_STORE_KEY);
if (result === null) {
return null;
}
if (!result.value) {
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(result.value);
} catch {
return null;
}
if (!isWellFormedParentalLockStore(parsed)) {
return null;
}
return normalizeParentalLockStore(parsed);
}
async writeLocks(store: ParentalLockStore): Promise<boolean> {
return this.write(
PARENTAL_LOCK_STORE_KEY,
JSON.stringify(normalizeParentalLockStore(store))
);
}
/** `null` when the read itself failed; an absent key is `{ value: null }`. */
private async read(key: string): Promise<{ value: string | null } | null> {
if (this.usesAppState) {
return this.databaseService.readAppState(key);
}
try {
return { value: localStorage.getItem(key) };
} catch {
return null;
}
}
private async write(key: string, value: string): Promise<boolean> {
if (this.usesAppState) {
return this.databaseService.setAppState(key, value);
}
try {
localStorage.setItem(key, value);
return localStorage.getItem(key) === value;
} catch {
return false;
}
}
}
@@ -0,0 +1,34 @@
import { removesParentalLocks } from './parental-lock-store.util';
describe('removesParentalLocks', () => {
const previous = {
xtream: [{ categoryType: 'live' as const, xtreamId: 7 }],
stalker: [{ categoryType: 'vod' as const, categoryId: '3' }],
m3u: ['Adults'],
};
it('is false when every previous lock is kept, extra locks or not', () => {
expect(removesParentalLocks(previous, previous)).toBe(false);
expect(
removesParentalLocks(previous, {
...previous,
m3u: ['Adults', 'News'],
})
).toBe(false);
});
it('is true when any portal list drops a lock', () => {
expect(
removesParentalLocks(previous, { ...previous, xtream: [] })
).toBe(true);
expect(
removesParentalLocks(previous, {
...previous,
stalker: [{ categoryType: 'itv', categoryId: '3' }],
})
).toBe(true);
expect(removesParentalLocks(previous, { ...previous, m3u: [] })).toBe(
true
);
});
});
@@ -0,0 +1,146 @@
import {
isParentalLockPlaylistLocksEmpty,
lockedXtreamCategoryIds,
normalizeParentalLockPlaylistLocks,
ParentalLockPlaylistLocks,
ParentalLockStalkerCategoryType,
ParentalLockStore,
ParentalLockXtreamCategoryType,
} from '@iptvnator/shared/interfaces';
/**
* Pure builders for the next lock set of one playlist: each replaces the
* entries of ONE category type (or the M3U group list) and leaves the other
* types untouched, so a dialog editing Live TV cannot drop the Movies locks.
*/
export function withXtreamLocks(
current: ParentalLockPlaylistLocks,
categoryType: ParentalLockXtreamCategoryType,
xtreamIds: readonly number[]
): ParentalLockPlaylistLocks {
return {
...current,
xtream: [
...current.xtream.filter(
(entry) => entry.categoryType !== categoryType
),
...[...new Set(xtreamIds)].map((xtreamId) => ({
categoryType,
xtreamId,
})),
],
};
}
export function withStalkerLocks(
current: ParentalLockPlaylistLocks,
categoryType: ParentalLockStalkerCategoryType,
categoryIds: readonly string[]
): ParentalLockPlaylistLocks {
return {
...current,
stalker: [
...current.stalker.filter(
(entry) => entry.categoryType !== categoryType
),
...[...new Set(categoryIds)].map((categoryId) => ({
categoryType,
categoryId,
})),
],
};
}
export function withM3uLocks(
current: ParentalLockPlaylistLocks,
groupTitles: readonly string[]
): ParentalLockPlaylistLocks {
return { ...current, m3u: [...new Set(groupTitles)] };
}
/** Whether `next` drops any lock that `previous` holds. */
export function removesParentalLocks(
previous: ParentalLockPlaylistLocks,
next: ParentalLockPlaylistLocks
): boolean {
const xtream = new Set(
next.xtream.map((lock) => `${lock.categoryType}:${lock.xtreamId}`)
);
const stalker = new Set(
next.stalker.map((lock) => `${lock.categoryType}:${lock.categoryId}`)
);
const m3u = new Set(next.m3u);
return (
previous.xtream.some(
(lock) => !xtream.has(`${lock.categoryType}:${lock.xtreamId}`)
) ||
previous.stalker.some(
(lock) => !stalker.has(`${lock.categoryType}:${lock.categoryId}`)
) ||
previous.m3u.some((title) => !m3u.has(title))
);
}
/**
* Whether an edit from `previous` to `next` may be issued: adding locks
* always may, removing one only while `mayRemove()` says so (logged when
* refused). Returned as a function so the answer is taken at the moment
* the edit's first write is issued.
*/
export function lockRemovalGate(
previous: ParentalLockPlaylistLocks,
next: ParentalLockPlaylistLocks,
mayRemove: () => boolean
): () => boolean {
const removes = removesParentalLocks(previous, next);
return () => {
if (!removes || mayRemove()) {
return true;
}
console.warn('A parental lock edit was refused: the app is locked.');
return false;
};
}
/** `store` with one playlist's locks replaced; an empty set drops its key. */
export function withPlaylistLocksInStore(
store: ParentalLockStore,
playlistId: string,
locks: ParentalLockPlaylistLocks
): ParentalLockStore {
const normalized = normalizeParentalLockPlaylistLocks(locks);
const next: ParentalLockStore = { ...store };
if (isParentalLockPlaylistLocksEmpty(normalized)) {
delete next[playlistId];
} else {
next[playlistId] = normalized;
}
return next;
}
/**
* Stamps the SQLite `categories.locked` index of one playlist from `locks`,
* type by type. Every type is attempted; false when any of them failed.
*/
export async function stampXtreamIndex(
setCategoryLocks: (
playlistId: string,
categoryType: ParentalLockXtreamCategoryType,
lockedXtreamIds: number[]
) => Promise<boolean>,
playlistId: string,
categoryTypes: readonly ParentalLockXtreamCategoryType[],
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
let success = true;
for (const categoryType of categoryTypes) {
success =
(await setCategoryLocks(
playlistId,
categoryType,
lockedXtreamCategoryIds(locks, categoryType)
)) && success;
}
return success;
}
@@ -0,0 +1,619 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { webcrypto } from 'node:crypto';
import { hashParentalLockPin } from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { SettingsStore } from '../settings-store.service';
import {
PARENTAL_LOCK_PROMPT,
ParentalLockPromptRequest,
} from './parental-lock-prompt.token';
import { ParentalLockStorageService } from './parental-lock-storage';
import { ParentalLockService } from './parental-lock.service';
describe('ParentalLockService', () => {
const originalElectron = window.electron;
let parentalLockEnabled: ReturnType<typeof signal<boolean>>;
let parentalLockRelockMinutes: ReturnType<typeof signal<number>>;
let storageFailure: ReturnType<typeof signal<'load' | 'save' | null>>;
let updateSettings: jest.Mock;
let storage: {
pinHash: string | null;
locks: Record<string, unknown>;
readPinHash: jest.Mock;
writePinHash: jest.Mock;
readLocks: jest.Mock;
writeLocks: jest.Mock;
};
let prompt: { requestPin: jest.Mock };
let setCategoryLocks: jest.Mock;
let setParentalLockState: jest.Mock;
let updateBridgeSettings: jest.Mock;
let runtime: {
supportsXtreamSqliteDataSource: boolean;
supportsParentalLockSqliteFilter: boolean;
};
beforeAll(() => {
if (!globalThis.crypto?.subtle) {
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: webcrypto,
});
}
});
beforeEach(() => {
parentalLockEnabled = signal(false);
parentalLockRelockMinutes = signal(15);
storageFailure = signal<'load' | 'save' | null>(null);
updateSettings = jest.fn(async (patch: Record<string, unknown>) => {
if ('parentalLockEnabled' in patch) {
parentalLockEnabled.set(patch['parentalLockEnabled'] === true);
}
if ('parentalLockRelockMinutes' in patch) {
parentalLockRelockMinutes.set(
patch['parentalLockRelockMinutes'] as number
);
}
});
storage = {
pinHash: null,
locks: {},
readPinHash: jest.fn(async () => ({ hash: storage.pinHash })),
writePinHash: jest.fn(async (hash: string) => {
storage.pinHash = hash;
return true;
}),
readLocks: jest.fn(async () => storage.locks),
writeLocks: jest.fn(async (locks: Record<string, unknown>) => {
storage.locks = locks;
return true;
}),
};
prompt = { requestPin: jest.fn() };
runtime = {
supportsXtreamSqliteDataSource: true,
supportsParentalLockSqliteFilter: true,
};
setCategoryLocks = jest.fn(async () => true);
setParentalLockState = jest.fn(async () => undefined);
updateBridgeSettings = jest.fn(async () => undefined);
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: {
setParentalLockState,
updateSettings: updateBridgeSettings,
},
});
TestBed.configureTestingModule({
providers: [
ParentalLockService,
{
provide: SettingsStore,
useValue: {
parentalLockEnabled,
parentalLockRelockMinutes,
storageFailure,
loadSettings: jest.fn(async () => undefined),
updateSettings,
},
},
{ provide: ParentalLockStorageService, useValue: storage },
{ provide: PARENTAL_LOCK_PROMPT, useValue: prompt },
{
provide: RuntimeCapabilitiesService,
useValue: runtime,
},
{ provide: DatabaseService, useValue: { setCategoryLocks } },
],
});
});
afterEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
value: originalElectron,
});
});
async function createService(): Promise<ParentalLockService> {
const service = TestBed.inject(ParentalLockService);
await service.initialize();
TestBed.flushEffects();
return service;
}
it('is inactive while the feature is off and reports that to the main process', async () => {
const service = await createService();
expect(service.enabled()).toBe(false);
expect(service.active()).toBe(false);
expect(service.hasPin()).toBe(false);
expect(setParentalLockState).toHaveBeenLastCalledWith(false);
await expect(service.requestUnlock()).resolves.toBe(true);
expect(prompt.requestPin).not.toHaveBeenCalled();
});
it('stands in the stored PIN for the switch when settings could not be read', async () => {
storage.pinHash = await hashParentalLockPin('1234');
storageFailure.set('load');
// Defaults-by-failure: the store reports the feature as off.
parentalLockEnabled.set(false);
const service = await createService();
expect(service.enabled()).toBe(true);
expect(service.active()).toBe(true);
expect(setParentalLockState).toHaveBeenLastCalledWith(true);
});
it('never announces an unlocked state on unreadable settings without a PIN', async () => {
storageFailure.set('load');
const service = await createService();
expect(service.enabled()).toBe(false);
expect(setParentalLockState).not.toHaveBeenCalled();
});
it('withholds every category while the lock store cannot be read', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
storage.readLocks.mockResolvedValue(null);
const service = await createService();
expect(service.active()).toBe(true);
expect(service.withholdsEverything()).toBe(true);
expect(service.isXtreamCategoryLocked('p', 'live', 1)).toBe(true);
expect(service.isStalkerCategoryLocked('p', 'itv', '1')).toBe(true);
expect(service.isStalkerCategoryLocked('p', 'itv', undefined)).toBe(
true
);
expect(service.isM3uGroupLocked('p', 'News')).toBe(true);
// Nothing is known about the persisted locks: a write built on the
// empty in-memory store would wipe them.
await expect(service.setM3uLocks('p', ['Adult'])).resolves.toBe(false);
expect(storage.writeLocks).not.toHaveBeenCalled();
// The store reads again: the real locks apply and writes resume.
storage.readLocks.mockResolvedValue({
p: { xtream: [], stalker: [], m3u: ['Adult'] },
});
await expect(service.setM3uLocks('p', ['Adult', 'XXX'])).resolves.toBe(
true
);
TestBed.flushEffects();
expect(service.withholdsEverything()).toBe(false);
expect(service.isM3uGroupLocked('p', 'News')).toBe(false);
expect(service.isM3uGroupLocked('p', 'XXX')).toBe(true);
});
it('withholds every category while locked when the worker filter bridge is missing', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
runtime.supportsParentalLockSqliteFilter = false;
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
const service = await createService();
expect(service.active()).toBe(true);
expect(service.locksReadable()).toBe(true);
expect(service.withholdsEverything()).toBe(true);
expect(service.isXtreamCategoryLocked('p', 'live', 1)).toBe(true);
prompt.requestPin.mockResolvedValue('1234');
await expect(service.requestUnlock()).resolves.toBe(true);
expect(service.withholdsEverything()).toBe(false);
});
it('withholds everything while locked when the worker lock-state sync rejects', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
setParentalLockState.mockRejectedValue(new Error('no handler'));
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
const service = await createService();
const version = service.version();
await Promise.resolve();
await Promise.resolve();
expect(service.active()).toBe(true);
expect(service.withholdsEverything()).toBe(true);
expect(service.version()).toBeGreaterThan(version);
// The sync of the next transition succeeds: normal filtering again.
setParentalLockState.mockResolvedValue(undefined);
prompt.requestPin.mockResolvedValue('1234');
await service.requestUnlock();
TestBed.flushEffects();
await Promise.resolve();
await Promise.resolve();
service.lock();
TestBed.flushEffects();
await Promise.resolve();
await Promise.resolve();
expect(service.withholdsEverything()).toBe(false);
});
it('withholds everything while the initial lock store read is in flight', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
let resolveLocks: (locks: Record<string, unknown>) => void = () =>
undefined;
storage.readLocks.mockReturnValue(
new Promise((resolve) => (resolveLocks = resolve))
);
const service = TestBed.inject(ParentalLockService);
const initialization = service.initialize();
await Promise.resolve();
expect(service.active()).toBe(true);
expect(service.withholdsEverything()).toBe(true);
expect(service.isM3uGroupLocked('p', 'News')).toBe(true);
resolveLocks({});
await initialization;
expect(service.withholdsEverything()).toBe(false);
expect(service.isM3uGroupLocked('p', 'News')).toBe(false);
});
it('rolls the switch back when enabling cannot be persisted', async () => {
prompt.requestPin.mockResolvedValue('1234');
updateSettings.mockImplementationOnce(async () => {
// updateSettings patches memory before the write fails.
parentalLockEnabled.set(true);
throw new Error('QuotaExceededError');
});
const service = await createService();
await expect(service.setupPin()).resolves.toBe(false);
expect(service.hasPin()).toBe(true);
expect(service.enabled()).toBe(false);
expect(service.unlocked()).toBe(false);
expect(updateBridgeSettings).not.toHaveBeenCalled();
});
it('retries a failed settings read before persisting the switch', async () => {
storageFailure.set('load');
prompt.requestPin.mockResolvedValue('1234');
const service = await createService();
// Storage recovers: the retry reads the persisted settings back.
const loadSettings = TestBed.inject(SettingsStore)
.loadSettings as jest.Mock;
loadSettings.mockImplementationOnce(async () => {
storageFailure.set(null);
});
await expect(service.setupPin()).resolves.toBe(true);
expect(loadSettings).toHaveBeenCalledTimes(2);
expect(updateSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
expect(updateBridgeSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
});
it('rolls a failed relock write back to the value recovered by the retry', async () => {
storageFailure.set('load');
const service = await createService();
const loadSettings = TestBed.inject(SettingsStore)
.loadSettings as jest.Mock;
loadSettings.mockImplementationOnce(async () => {
storageFailure.set(null);
parentalLockRelockMinutes.set(60);
});
updateSettings.mockImplementationOnce(async () => {
parentalLockRelockMinutes.set(30);
throw new Error('QuotaExceededError');
});
await expect(service.setRelockMinutes(30)).resolves.toBe(false);
expect(updateSettings).toHaveBeenLastCalledWith({
parentalLockRelockMinutes: 60,
});
});
it('refuses parental-lock settings writes while settings stay unreadable', async () => {
storageFailure.set('load');
prompt.requestPin.mockResolvedValue('1234');
const service = await createService();
// Writing now would replace the persisted settings with defaults.
await expect(service.setupPin()).resolves.toBe(false);
await expect(service.setRelockMinutes(30)).resolves.toBe(false);
expect(updateSettings).not.toHaveBeenCalled();
expect(prompt.requestPin).not.toHaveBeenCalled();
});
it('keeps the session locked on a failed PIN read and retries before unlocking', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
storage.readPinHash.mockResolvedValueOnce(null);
prompt.requestPin.mockImplementation(
async (request: ParentalLockPromptRequest) =>
(await request.verify?.('1234')) ? '1234' : null
);
const service = await createService();
expect(service.active()).toBe(true);
expect(service.hasPin()).toBe(false);
await expect(service.requestUnlock()).resolves.toBe(true);
expect(service.hasPin()).toBe(true);
expect(service.unlocked()).toBe(true);
});
it('refuses a relock timeout change while the session is locked', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
const service = await createService();
expect(service.active()).toBe(true);
await expect(service.setRelockMinutes(0)).resolves.toBe(false);
expect(updateSettings).not.toHaveBeenCalledWith(
expect.objectContaining({ parentalLockRelockMinutes: 0 })
);
expect(service.relockMinutes()).toBe(15);
});
it('rolls the relock timeout back when it cannot be persisted', async () => {
updateSettings.mockImplementationOnce(async () => {
parentalLockRelockMinutes.set(30);
throw new Error('QuotaExceededError');
});
const service = await createService();
await expect(service.setRelockMinutes(30)).resolves.toBe(false);
expect(service.relockMinutes()).toBe(15);
expect(updateSettings).toHaveBeenLastCalledWith({
parentalLockRelockMinutes: 15,
});
});
it('rolls the switch back when the Electron mirror cannot be written', async () => {
prompt.requestPin.mockResolvedValue('1234');
updateBridgeSettings.mockRejectedValueOnce(new Error('ipc'));
const service = await createService();
await expect(service.setupPin()).resolves.toBe(false);
expect(service.enabled()).toBe(false);
expect(service.unlocked()).toBe(false);
expect(updateSettings).toHaveBeenLastCalledWith({
parentalLockEnabled: false,
});
});
it('starts locked with the feature on and unlocks through the prompt', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
prompt.requestPin.mockImplementation(
async (request: ParentalLockPromptRequest) =>
(await request.verify?.('1234')) ? '1234' : null
);
const service = await createService();
expect(service.active()).toBe(true);
expect(setParentalLockState).toHaveBeenLastCalledWith(true);
const unlocked = service.requestUnlock();
const concurrent = service.requestUnlock();
await expect(unlocked).resolves.toBe(true);
await expect(concurrent).resolves.toBe(true);
expect(prompt.requestPin).toHaveBeenCalledTimes(1);
TestBed.flushEffects();
expect(service.unlocked()).toBe(true);
expect(service.active()).toBe(false);
expect(setParentalLockState).toHaveBeenLastCalledWith(false);
service.lock();
TestBed.flushEffects();
expect(service.active()).toBe(true);
expect(setParentalLockState).toHaveBeenLastCalledWith(true);
});
it('does not open a gate before the settings have loaded', async () => {
storage.pinHash = await hashParentalLockPin('1234');
prompt.requestPin.mockResolvedValue(null);
let releaseSettings!: () => void;
const settingsStore = TestBed.inject(SettingsStore) as unknown as {
loadSettings: jest.Mock;
};
settingsStore.loadSettings.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseSettings = () => {
parentalLockEnabled.set(true);
resolve();
};
})
);
const service = TestBed.inject(ParentalLockService);
void service.initialize();
// Enabled is still the default false here: the answer must wait for
// the load instead of reading that as "feature off".
const gate = service.requestUnlock();
releaseSettings();
await expect(gate).resolves.toBe(false);
expect(prompt.requestPin).toHaveBeenCalledTimes(1);
});
it('stays locked when the prompt is dismissed', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
prompt.requestPin.mockResolvedValue(null);
const service = await createService();
await expect(service.requestUnlock()).resolves.toBe(false);
expect(service.active()).toBe(true);
});
it('setupPin stores a hash, enables the feature and leaves the parent unlocked', async () => {
prompt.requestPin.mockResolvedValue('9876');
const service = await createService();
await expect(service.setupPin()).resolves.toBe(true);
TestBed.flushEffects();
expect(storage.writePinHash).toHaveBeenCalledTimes(1);
expect(storage.pinHash).not.toContain('9876');
expect(updateSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
expect(updateBridgeSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
expect(service.enabled()).toBe(true);
expect(service.unlocked()).toBe(true);
expect(service.hasPin()).toBe(true);
});
it('disable requires the PIN and keeps the locks', async () => {
storage.pinHash = await hashParentalLockPin('1234');
storage.locks = { 'p-1': { m3u: ['Adult'] } };
parentalLockEnabled.set(true);
prompt.requestPin.mockResolvedValueOnce(null);
const service = await createService();
await expect(service.disable()).resolves.toBe(false);
expect(service.enabled()).toBe(true);
prompt.requestPin.mockResolvedValueOnce('1234');
await expect(service.disable()).resolves.toBe(true);
expect(service.enabled()).toBe(false);
expect(service.lockedGroupTitles('p-1')).toEqual(['Adult']);
});
it('arms the idle timer for the session that just enabled the feature', async () => {
jest.useFakeTimers();
try {
prompt.requestPin.mockResolvedValue('9876');
parentalLockRelockMinutes.set(5);
const service = await createService();
await expect(service.setupPin()).resolves.toBe(true);
TestBed.flushEffects();
expect(service.unlocked()).toBe(true);
// `active` never changed (unlocked before and after enabling), so
// the timer must be armed by the unlocked transition itself.
jest.advanceTimersByTime(5 * 60_000 + 1_500);
TestBed.flushEffects();
expect(service.unlocked()).toBe(false);
expect(service.active()).toBe(true);
} finally {
jest.useRealTimers();
}
});
it('changePin and disable verify the stored PIN even while the session is unlocked', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
prompt.requestPin.mockImplementation(
async (request: ParentalLockPromptRequest) =>
request.mode === 'set'
? '5678'
: (await request.verify?.('1234'))
? '1234'
: null
);
const service = await createService();
await service.requestUnlock();
TestBed.flushEffects();
expect(service.unlocked()).toBe(true);
prompt.requestPin.mockClear();
// A dismissed verification prompt aborts the change.
prompt.requestPin.mockResolvedValueOnce(null);
await expect(service.changePin()).resolves.toBe(false);
expect(prompt.requestPin).toHaveBeenCalledTimes(1);
expect(prompt.requestPin.mock.calls[0][0].mode).toBe('unlock');
expect(storage.writePinHash).not.toHaveBeenCalled();
prompt.requestPin.mockClear();
await expect(service.changePin()).resolves.toBe(true);
expect(
prompt.requestPin.mock.calls.map((call) => call[0].mode)
).toEqual(['unlock', 'set']);
expect(storage.writePinHash).toHaveBeenCalledTimes(1);
prompt.requestPin.mockClear();
prompt.requestPin.mockResolvedValueOnce(null);
await expect(service.disable()).resolves.toBe(false);
expect(service.enabled()).toBe(true);
expect(prompt.requestPin.mock.calls[0][0].mode).toBe('unlock');
});
it('persists locks per portal, stamps the Xtream column and bumps the version', async () => {
const service = await createService();
const versionBefore = service.version();
await expect(
service.setXtreamLocks('p-1', 'live', [3, 3, 9])
).resolves.toBe(true);
await expect(
service.setStalkerLocks('p-2', 'itv', ['12'])
).resolves.toBe(true);
await expect(service.setM3uLocks('p-3', ['XXX'])).resolves.toBe(true);
expect(setCategoryLocks).toHaveBeenCalledWith('p-1', 'live', [3, 9]);
expect(service.lockedXtreamIds('p-1', 'live')).toEqual([3, 9]);
expect(service.lockedXtreamIds('p-1', 'movies')).toEqual([]);
expect(service.lockedStalkerIds('p-2', 'itv')).toEqual(['12']);
expect(service.lockedGroupTitles('p-3')).toEqual(['XXX']);
expect(service.version()).toBeGreaterThan(versionBefore);
expect(storage.writeLocks).toHaveBeenCalledTimes(3);
});
it('answers "locked" only while the lock is active', async () => {
storage.pinHash = await hashParentalLockPin('1234');
storage.locks = {
'p-1': {
xtream: [{ categoryType: 'live', xtreamId: 3 }],
stalker: [{ categoryType: 'vod', categoryId: '7' }],
m3u: ['XXX'],
},
};
parentalLockEnabled.set(true);
prompt.requestPin.mockResolvedValue('1234');
const service = await createService();
expect(service.isXtreamCategoryLocked('p-1', 'live', 3)).toBe(true);
expect(service.isXtreamCategoryLocked('p-1', 'live', 4)).toBe(false);
expect(service.isStalkerCategoryLocked('p-1', 'vod', 7)).toBe(true);
expect(service.isM3uGroupLocked('p-1', 'XXX')).toBe(true);
await service.requestUnlock();
expect(service.isXtreamCategoryLocked('p-1', 'live', 3)).toBe(false);
expect(service.isM3uGroupLocked('p-1', 'XXX')).toBe(false);
});
it('replacePlaylistLocks re-stamps every Xtream type', async () => {
const service = await createService();
await service.replacePlaylistLocks('p-1', {
xtream: [{ categoryType: 'movies', xtreamId: 5 }],
stalker: [],
m3u: [],
});
expect(setCategoryLocks).toHaveBeenCalledWith('p-1', 'live', []);
expect(setCategoryLocks).toHaveBeenCalledWith('p-1', 'movies', [5]);
expect(setCategoryLocks).toHaveBeenCalledWith('p-1', 'series', []);
});
});
@@ -0,0 +1,548 @@
import {
computed,
effect,
inject,
Injectable,
signal,
untracked,
} from '@angular/core';
import {
createParentalLockPinThrottle,
hashParentalLockPin,
normalizeParentalLockRelockMinutes,
ParentalLockPlaylistLocks,
ParentalLockStalkerCategoryType,
ParentalLockXtreamCategoryType,
verifyParentalLockPin,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { SettingsStore } from '../settings-store.service';
import { ParentalLockIdleTimer } from './parental-lock-idle-timer';
import {
LockListEdit,
ParentalLockLockStore,
} from './parental-lock-lock-store.service';
import {
PARENTAL_LOCK_PROMPT,
ParentalLockPromptRequest,
} from './parental-lock-prompt.token';
import {
isParentalLockWorkerFilterMissing,
ParentalLockWorkerSync,
} from './parental-lock-bridge';
import {
ensureParentalLockSettingsReadable,
persistParentalLockEnabled,
persistParentalLockRelockMinutes,
} from './parental-lock-settings-writer';
import { ParentalLockStorageService } from './parental-lock-storage';
/**
* Parental lock: hides locked categories everywhere until the PIN is
* entered.
*
* Source of truth for WHICH categories are locked is the lock store held
* here (persisted through `ParentalLockStorageService`); the Electron
* `categories.locked` column is only a query index derived from it, so the
* SQLite worker can filter reads server-side.
*
* `active` is the enforcement flag: feature enabled and no PIN entered.
* Everything that renders catalog content asks this service — the Xtream
* data sources, the Stalker store, the M3U channel list, the workspace
* rails — and re-queries when `version` changes. The unlock lives in memory
* only: a restart always locks again, and so does the idle timer.
*/
@Injectable({ providedIn: 'root' })
export class ParentalLockService {
private readonly settingsStore = inject(SettingsStore);
private readonly storage = inject(ParentalLockStorageService);
private readonly locks = inject(ParentalLockLockStore);
private readonly prompt = inject(PARENTAL_LOCK_PROMPT, { optional: true });
/**
* Electron reads Xtream through the SQLite worker, but the bridge lacks
* the lock-state or index IPC (a partial or older preload): the worker
* cannot withhold locked rows, so the locked session withholds all.
*/
private readonly workerFilterMissing = isParentalLockWorkerFilterMissing(
inject(RuntimeCapabilitiesService)
);
private readonly unlockedState = signal(false);
/** Shared by every unlock prompt: a reopened dialog keeps the cooldown. */
private readonly pinThrottle = createParentalLockPinThrottle();
private readonly pinHash = signal<string | null>(null);
/** The PIN hash could not be read; retried before PIN-protected steps. */
private readonly pinUnreadable = signal(false);
private readonly versionState = signal(0);
private readonly workerSync = new ParentalLockWorkerSync();
// The main process starts LOCKED whenever the feature is on (mirrored
// setting). Reporting our state before settings have loaded would send a
// spurious "unlocked" and open a window of unfiltered reads.
private readonly settingsReady = signal(false);
private readonly busyProbes = new Set<() => boolean>();
private readonly idleTimer = new ParentalLockIdleTimer({
onExpire: () => this.lock(),
isBusy: () => [...this.busyProbes].some((probe) => probe()),
});
private initialization: Promise<void> | null = null;
private pendingUnlock: Promise<boolean> | null = null;
/**
* The persisted feature switch could not be read (IndexedDB failure):
* `SettingsStore` then serves defaults, which would read as "off".
*/
private readonly switchUnknown = computed(
() => this.settingsStore.storageFailure?.() === 'load'
);
/**
* The feature switch from settings. While the switch is unknown a stored
* PIN stands in for it: the PIN exists only once a parent set the lock
* up, so failing toward "locked" costs one PIN entry, whereas failing
* toward "off" would expose every locked category precisely during a
* storage failure.
*/
readonly enabled = computed(() =>
this.switchUnknown()
? this.pinHash() !== null || this.pinUnreadable()
: this.settingsStore.parentalLockEnabled?.() === true
);
/** Feature on and the PIN has been entered this session. */
readonly unlocked = computed(() => this.enabled() && this.unlockedState());
/** Locked categories must currently be withheld. */
readonly active = computed(() => this.enabled() && !this.unlockedState());
/** A PIN exists; enabling is only possible once this is true. */
readonly hasPin = computed(() => this.pinHash() !== null);
/** The lock store is read and trustworthy; backups must not run without it. */
readonly locksReadable = computed(() => this.locks.readable());
/**
* Locked, and the lock store could not be read: which categories are
* locked is unknown, so EVERY category is withheld — the renderer-side
* filters treat every id as locked — until the PIN is entered or the
* store reads again. Failing toward "nothing is locked" would expose the
* protected content precisely during a storage failure.
*/
readonly withholdsEverything = computed(
() =>
this.active() &&
(this.workerFilterMissing ||
this.workerSync.failed() ||
!this.locks.readable())
);
/** Bumps whenever `active` or the lock store changes; consumers re-query. */
readonly version = computed(
() =>
this.versionState() +
this.locks.revision() +
this.workerSync.changes()
);
readonly relockMinutes = computed(() =>
normalizeParentalLockRelockMinutes(
this.settingsStore.parentalLockRelockMinutes?.()
)
);
constructor() {
// Lock edits that take a lock away need an unlocked session at the
// moment they commit, not only when their editor opened.
this.locks.setRemovalGate(() => !this.active());
effect(() => {
const active = this.active();
if (!this.settingsReady()) {
return;
}
untracked(() => {
this.versionState.update((value) => value + 1);
// Unknown switch and no PIN to stand in for it: the main
// process keeps its mirrored (locked) default rather than
// being told "unlocked" on the strength of default settings.
if (!this.switchUnknown() || this.hasPin()) {
void this.workerSync.sync(active, () => this.active());
}
});
});
// The idle timer follows the UNLOCKED transition, not `active`:
// enabling the feature from an unlocked session (setupPin) never
// changes `active`, yet that session must still lock itself later.
effect(() => {
const unlocked = this.unlocked();
const minutes = this.relockMinutes();
untracked(() => {
if (unlocked) {
this.idleTimer.arm(minutes);
} else {
this.idleTimer.disarm();
}
});
});
}
/** Loads the persisted PIN hash and lock store once. */
initialize(): Promise<void> {
if (!this.initialization) {
this.initialization = (async () => {
const [pinRead] = await Promise.all([
this.storage.readPinHash(),
this.locks.load(),
this.settingsStore.loadSettings(),
]);
this.applyPinRead(pinRead);
this.versionState.update((value) => value + 1);
this.settingsReady.set(true);
})().catch((error) => {
console.error('Failed to load the parental lock state.', error);
this.settingsReady.set(true);
});
}
return this.initialization;
}
/**
* Something that should keep the app unlocked while true, e.g. a playing
* video. Returns the unregister function.
*/
registerBusyProbe(probe: () => boolean): () => void {
this.busyProbes.add(probe);
return () => this.busyProbes.delete(probe);
}
/**
* Asks for the PIN when the lock is active. Resolves true when the app
* is unlocked afterwards (already unlocked, feature off, or the PIN was
* accepted). Concurrent callers share one prompt.
*/
async requestUnlock(
options: Pick<
ParentalLockPromptRequest,
'titleKey' | 'descriptionKey'
> = {}
): Promise<boolean> {
// `enabled` is false until the settings have loaded; deciding "not
// active" before that would open a gate during a slow startup.
await this.initialize();
if (!this.active()) {
return true;
}
// A store that failed to read at startup may read now; do not send
// the user through the PIN for categories that are not locked.
await this.locks.ensureReadable();
if (this.pendingUnlock) {
return this.pendingUnlock;
}
this.pendingUnlock = this.promptForUnlock(options).finally(() => {
this.pendingUnlock = null;
});
return this.pendingUnlock;
}
private async promptForUnlock(
options: Pick<ParentalLockPromptRequest, 'titleKey' | 'descriptionKey'>
): Promise<boolean> {
await this.ensurePin();
const hash = this.pinHash();
if (!this.prompt || !hash) {
return false;
}
const pin = await this.prompt.requestPin({
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
throttle: this.pinThrottle,
...options,
});
if (pin === null) {
return false;
}
this.unlockedState.set(true);
return true;
}
/** Locks immediately; the next locked surface needs the PIN again. */
lock(): void {
this.unlockedState.set(false);
}
/**
* Sets a new PIN through the prompt and turns the feature on. The
* parent who just typed the PIN stays unlocked. Returns whether it
* happened.
*/
async setupPin(): Promise<boolean> {
await this.initialize();
if (!this.prompt || !(await this.ensureSettingsReadable())) {
return false;
}
// Decided BEFORE the PIN is stored: with the settings switch
// unknown, `enabled` follows `hasPin`, and would read as on the
// moment the PIN lands — skipping the persistence the next launch
// depends on.
const needsPersist =
this.settingsStore.parentalLockEnabled?.() !== true;
const pin = await this.prompt.requestPin({ mode: 'set' });
if (pin === null) {
return false;
}
if (!(await this.storePin(pin))) {
return false;
}
this.unlockedState.set(true);
if (needsPersist && !(await this.persistEnabled(true))) {
this.unlockedState.set(false);
return false;
}
return true;
}
private persistEnabled(enabled: boolean): Promise<boolean> {
return persistParentalLockEnabled(this.settingsStore, enabled);
}
private ensureSettingsReadable(): Promise<boolean> {
return ensureParentalLockSettingsReadable(this.settingsStore);
}
/** Verifies the current PIN, then replaces it. */
async changePin(): Promise<boolean> {
await this.initialize();
await this.ensurePin();
if (!this.prompt || !this.hasPin()) {
return false;
}
if (!(await this.verifyCurrentPin())) {
return false;
}
const pin = await this.prompt.requestPin({ mode: 'set' });
if (pin === null) {
return false;
}
return this.storePin(pin);
}
/** Turns the feature off after the PIN was entered; locks are kept. */
async disable(): Promise<boolean> {
if (!this.enabled()) {
return true;
}
if (!(await this.verifyCurrentPin())) {
return false;
}
if (!(await this.persistEnabled(false))) {
return false;
}
this.unlockedState.set(false);
return true;
}
/**
* Always asks for the PIN, unlocked session or not: changing the PIN or
* switching the feature off must not be possible just because a parent
* left the app unlocked. Unlike `requestUnlock()` this never short-cuts
* on `active`.
*/
private async verifyCurrentPin(): Promise<boolean> {
await this.initialize();
await this.ensurePin();
const hash = this.pinHash();
if (!this.prompt || !hash) {
return false;
}
const pin = await this.prompt.requestPin({
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
throttle: this.pinThrottle,
titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE',
descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION',
});
return pin !== null;
}
/**
* False when the value could not be persisted; the in-memory patch
* `updateSettings` applied before the failed write is undone so the
* timer on screen never differs from the one the next launch uses.
*/
async setRelockMinutes(minutes: number): Promise<boolean> {
// A locked session must not lengthen or switch off its own relock
// timer (Settings disables the selector until the PIN is entered).
if (this.active()) {
return false;
}
return persistParentalLockRelockMinutes(
this.settingsStore,
minutes,
() => this.relockMinutes()
);
}
// -- Lock store (ParentalLockLockStore; predicates add `active`) -------
locksFor(playlistId: string): ParentalLockPlaylistLocks {
return this.locks.locksFor(playlistId);
}
lockedXtreamIds(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType
): number[] {
return this.locks.lockedXtreamIds(playlistId, categoryType);
}
lockedStalkerIds(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType
): string[] {
return this.locks.lockedStalkerIds(playlistId, categoryType);
}
lockedGroupTitles(playlistId: string): string[] {
return this.locks.lockedGroupTitles(playlistId);
}
/** Whether the category is locked AND currently withheld. */
isXtreamCategoryLocked(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType,
xtreamId: number
): boolean {
return (
this.active() &&
(this.withholdsEverything() ||
this.lockedXtreamIds(playlistId, categoryType).includes(
xtreamId
))
);
}
isStalkerCategoryLocked(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType,
categoryId: string | number | null | undefined
): boolean {
if (!this.active()) {
return false;
}
// Fail-closed mode withholds rows without a genre too: "unknown
// genre" is not "no locked genre" while the locks are unknown.
if (this.withholdsEverything()) {
return true;
}
if (categoryId === null || categoryId === undefined) {
return false;
}
return this.lockedStalkerIds(playlistId, categoryType).includes(
String(categoryId)
);
}
isM3uGroupLocked(playlistId: string, groupTitle: string): boolean {
return (
this.active() &&
(this.withholdsEverything() ||
this.lockedGroupTitles(playlistId).includes(groupTitle))
);
}
async setXtreamLocks(
playlistId: string,
categoryType: ParentalLockXtreamCategoryType,
xtreamIds: LockListEdit<number>
): Promise<boolean> {
await this.initialize();
return this.locks.setXtreamLocks(playlistId, categoryType, xtreamIds);
}
async setStalkerLocks(
playlistId: string,
categoryType: ParentalLockStalkerCategoryType,
categoryIds: LockListEdit<string>
): Promise<boolean> {
await this.initialize();
return this.locks.setStalkerLocks(
playlistId,
categoryType,
categoryIds
);
}
async setM3uLocks(
playlistId: string,
groupTitles: LockListEdit<string>
): Promise<boolean> {
await this.initialize();
return this.locks.setM3uLocks(playlistId, groupTitles);
}
/** Retries a failed lock-store read; false while it still fails. */
async ensureLocksReadable(): Promise<boolean> {
await this.initialize();
return this.locks.ensureReadable();
}
/** Playlist deletion: its locks leave the store. */
async removePlaylistLocks(playlistId: string): Promise<boolean> {
await this.initialize();
return this.locks.removePlaylist(playlistId);
}
/** "Remove all playlists": the whole lock store is emptied. */
async clearAllLocks(): Promise<boolean> {
await this.initialize();
return this.locks.clearAll();
}
/** Backup restore: replaces every lock of one playlist. */
async replacePlaylistLocks(
playlistId: string,
locks: ParentalLockPlaylistLocks
): Promise<boolean> {
await this.initialize();
return this.locks.replacePlaylistLocks(playlistId, locks);
}
/**
* Re-stamps the Electron `categories.locked` index for a playlist from
* the store, e.g. after a refresh recreated the rows.
*/
stampXtreamLocks(
playlistId: string,
categoryTypes?: readonly ParentalLockXtreamCategoryType[]
): Promise<boolean> {
return this.locks.stampXtreamLocks(playlistId, categoryTypes);
}
private applyPinRead(read: { hash: string | null } | null): void {
if (read === null) {
console.error('The parental lock PIN could not be read.');
this.pinUnreadable.set(true);
return;
}
this.pinHash.set(read.hash);
this.pinUnreadable.set(false);
}
/**
* Retries a PIN read that failed at startup. A failed read is not an
* absent PIN: the session stays locked (`enabled` treats it as set),
* and every PIN-protected step re-reads first so the parent can still
* unlock, change the PIN or switch the feature off once storage answers.
*/
private async ensurePin(): Promise<void> {
if (!this.pinUnreadable()) {
return;
}
this.applyPinRead(await this.storage.readPinHash());
}
private async storePin(pin: string): Promise<boolean> {
try {
const hash = await hashParentalLockPin(pin);
if (!(await this.storage.writePinHash(hash))) {
return false;
}
this.pinHash.set(hash);
this.pinUnreadable.set(false);
return true;
} catch (error) {
console.error('Failed to store the parental lock PIN.', error);
return false;
}
}
}
@@ -69,8 +69,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
const backup = await service.exportBackup();
const entry = backup.manifest
.playlists[0] as XtreamPlaylistBackupEntry;
const entry = backup.manifest.playlists[0] as XtreamPlaylistBackupEntry;
// Without this every "main source" choice vanishes on restore, with
// nothing in the archive to say it was ever made.
expect(entry.userState.sourcePins).toEqual([
@@ -161,7 +160,8 @@ describe('PlaylistBackupService Xtream source pins', () => {
await Promise.all([firstImport, secondImport]);
expect(
replacePins.mock.calls.map(
([, pins]) => (pins as Array<{ contentId: number }>)[0].contentId
([, pins]) =>
(pins as Array<{ contentId: number }>)[0].contentId
)
).toEqual([501, 502]);
});
@@ -229,9 +229,11 @@ describe('PlaylistBackupService export → import round-trip', () => {
}),
]);
expect(state.epgUrls).toEqual(['https://epg.example.com/guide.xml']);
expect(
state.playlists.map((playlist) => playlist._id)
).toEqual(['m3u-1', 'xtream-1', 'stalker-1']);
expect(state.playlists.map((playlist) => playlist._id)).toEqual([
'm3u-1',
'xtream-1',
'stalker-1',
]);
// Exporting the restored state must reproduce the original
// manifest byte for byte (modulo the export timestamp): any field
@@ -14,6 +14,23 @@ describe('PlaylistBackupService', () => {
localStorage.clear();
});
it('refuses to export while the parental lock store is not readable', async () => {
const initialize = jest.fn().mockResolvedValue(undefined);
const service = createPlaylistBackupService({
parentalLock: {
initialize,
locksReadable: jest.fn(() => false),
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
},
});
await expect(service.exportBackup()).rejects.toThrow(
/parental lock store/
);
expect(initialize).toHaveBeenCalled();
});
it('exports self-contained M3U data and strips Stalker session fields', async () => {
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
@@ -56,18 +56,13 @@ export function createPlaylistBackupService(
? 'consumed'
: 'superseded';
}
if (
pendingSnapshot.revision !== expectedSnapshot.revision
) {
if (pendingSnapshot.revision !== expectedSnapshot.revision) {
return 'superseded';
}
await apply(pendingSnapshot.state);
if (
!pendingRestoreService.clear(
playlistId,
pendingSnapshot.state
)
!pendingRestoreService.clear(playlistId, pendingSnapshot.state)
) {
return 'consume-failed';
}
@@ -125,6 +120,14 @@ export function createPlaylistBackupService(
clearForPlaylist: jest.fn().mockResolvedValue(true),
},
pendingRestoreService,
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
requestUnlock: jest.fn().mockResolvedValue(true),
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
},
...overrides,
});
+218 -16
View File
@@ -6,7 +6,10 @@ import { DatabaseService } from './database-electron.service';
import { VodSourcePinService } from './vod-source-pin.service';
import { PlaybackPositionService } from './playback-position.service';
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
import { ParentalLockService } from './parental-lock/parental-lock.service';
import { removesParentalLocks } from './parental-lock/parental-lock-store.util';
import {
isParentalLockPlaylistLocksEmpty,
isM3uRecentlyViewedItem,
normalizeXtreamPendingRestoreState,
M3uPlaylistBackupEntry,
@@ -25,6 +28,15 @@ import {
XtreamBackupSourcePin,
XtreamPendingRestoreState,
createRandomId,
isWellFormedParentalLockGroupTitles,
isWellFormedParentalLockStalkerCategories,
isWellFormedParentalLockXtreamCategories,
normalizeParentalLockGroupTitles,
normalizeParentalLockStalkerCategories,
normalizeParentalLockXtreamCategories,
ParentalLockPlaylistLocks,
StalkerBackupLockedCategory,
XtreamBackupHiddenCategory,
} from '@iptvnator/shared/interfaces';
export interface PlaylistBackupExportPayload {
@@ -65,9 +77,19 @@ export class PlaylistBackupService {
private readonly pendingRestoreService = inject(
XtreamPendingRestoreService
);
private readonly parentalLock = inject(ParentalLockService);
private backupImportTail?: Promise<void>;
async exportBackup(): Promise<PlaylistBackupExportPayload> {
// Locks are written only when present, and an absent field means
// "no opinion" on restore — so an export must never run against an
// empty in-memory store that merely has not loaded, or could not.
await this.parentalLock.initialize();
if (!this.parentalLock.locksReadable()) {
throw new Error(
'The parental lock store could not be read; the backup would omit its locks.'
);
}
const playlists = await firstValueFrom(
this.playlistsService.getAllData()
);
@@ -96,9 +118,9 @@ export class PlaylistBackupService {
}
importBackup(json: string): Promise<PlaylistBackupImportSummary> {
const importResult = (
this.backupImportTail ?? Promise.resolve()
).then(() => this.executeImportBackup(json));
const importResult = (this.backupImportTail ?? Promise.resolve()).then(
() => this.executeImportBackup(json)
);
this.backupImportTail = importResult.then(
() => undefined,
() => undefined
@@ -110,6 +132,24 @@ export class PlaylistBackupService {
json: string
): Promise<PlaylistBackupImportSummary> {
const manifest = this.parseManifest(json);
// A backup carrying lock lists REPLACES the matching playlists'
// locks, possibly with an emptier set: that is a lock edit, and lock
// edits sit behind the PIN. Asked here, after the file was chosen.
const carriesLocks = manifest.playlists.some((entry) => {
const state = entry.userState as {
lockedCategories?: unknown;
lockedGroupTitles?: unknown;
};
return (
state?.lockedCategories !== undefined ||
state?.lockedGroupTitles !== undefined
);
});
if (carriesLocks && !(await this.parentalLock.requestUnlock())) {
throw new PlaylistBackupError(
'Enter the parental PIN to restore a backup that carries parental locks.'
);
}
const existingPlaylists = await firstValueFrom(
this.playlistsService.getAllData()
);
@@ -167,6 +207,11 @@ export class PlaylistBackupService {
await this.restoreXtreamEntry(targetId, entry);
}
// Last: a failed Xtream restore above must leave the
// playlist's previous locks in place rather than commit the
// backup's (possibly smaller) lock set for a failed merge.
await this.restoreParentalLocks(targetId, entry, isMerge);
if (isMerge) {
summary.merged += 1;
} else {
@@ -243,6 +288,7 @@ export class PlaylistBackupService {
? playlist.hiddenGroupTitles
: []
),
...this.optionalLockedGroupTitles(playlist._id),
},
};
}
@@ -269,6 +315,7 @@ export class PlaylistBackupService {
favorites: [],
recentlyViewed: [],
playbackPositions: [],
...this.optionalLockedXtreamCategories(playlist._id),
// NOT `[]`. Pins are Electron-only, so out here we cannot
// read them — which is not the same as knowing there are
// none. Restore treats the collection as authoritative and
@@ -321,6 +368,7 @@ export class PlaylistBackupService {
...this.mapHiddenCategories(movieCategories, 'movies'),
...this.mapHiddenCategories(seriesCategories, 'series'),
],
...this.optionalLockedXtreamCategories(playlist._id),
favorites: favorites.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamContentType,
@@ -416,6 +464,7 @@ export class PlaylistBackupService {
: {}),
},
userState: {
...this.optionalLockedStalkerCategories(playlist._id),
favorites: this.extractStalkerItems(playlist.favorites),
recentlyViewed: this.extractStalkerItems(
playlist.recentlyViewed
@@ -502,6 +551,19 @@ export class PlaylistBackupService {
`M3U backup "${entry.title}" is missing raw playlist data.`
);
}
// Restore replaces the playlist's locks with the archive's,
// and the normalizer drops what it does not understand — a
// damaged lock list would erase the persisted protection.
if (
entry.userState?.lockedGroupTitles !== undefined &&
!isWellFormedParentalLockGroupTitles(
entry.userState.lockedGroupTitles
)
) {
throw new PlaylistBackupError(
`M3U backup "${entry.title}" has invalid parental locks.`
);
}
break;
case 'xtream':
if (
@@ -529,6 +591,17 @@ export class PlaylistBackupService {
);
}
if (
entry.userState.lockedCategories !== undefined &&
!isWellFormedParentalLockXtreamCategories(
entry.userState.lockedCategories
)
) {
throw new PlaylistBackupError(
`Xtream backup "${entry.title}" has invalid parental locks.`
);
}
// Absent in archives written before multi-source existed, so
// its absence is not damage — only a wrong type is.
if (
@@ -549,6 +622,16 @@ export class PlaylistBackupService {
`Stalker backup "${entry.title}" is missing connection metadata.`
);
}
if (
entry.userState?.lockedCategories !== undefined &&
!isWellFormedParentalLockStalkerCategories(
entry.userState.lockedCategories
)
) {
throw new PlaylistBackupError(
`Stalker backup "${entry.title}" has invalid parental locks.`
);
}
break;
default:
throw new PlaylistBackupError(
@@ -706,11 +789,12 @@ export class PlaylistBackupService {
playlistId: string,
existing: Playlist | null
): Promise<Playlist> {
const parsedPlaylist = await this.playlistsService.handlePlaylistParsing(
'TEXT',
entry.source.rawM3u,
entry.title
);
const parsedPlaylist =
await this.playlistsService.handlePlaylistParsing(
'TEXT',
entry.source.rawM3u,
entry.title
);
const now = new Date().toISOString();
return {
@@ -829,7 +913,6 @@ export class PlaylistBackupService {
});
}
private async restoreXtreamEntry(
playlistId: string,
entry: XtreamPlaylistBackupEntry
@@ -859,13 +942,12 @@ export class PlaylistBackupService {
return;
}
const restoreResult =
await this.pendingRestoreService.applyAndConsume(
playlistId,
restoreSnapshot,
(pendingState) =>
this.applyXtreamRestoreState(playlistId, pendingState)
);
const restoreResult = await this.pendingRestoreService.applyAndConsume(
playlistId,
restoreSnapshot,
(pendingState) =>
this.applyXtreamRestoreState(playlistId, pendingState)
);
if (restoreResult !== 'consumed') {
throw new PlaylistBackupError(
`Clearing pending restore state for "${playlistId}" failed.`
@@ -1011,6 +1093,126 @@ export class PlaylistBackupService {
}
}
// Parental locks are written only when the playlist has some: an archive
// without the field says nothing about locks (older builds wrote none),
// so an unlocked playlist stays byte-identical to a pre-lock export.
private optionalLockedXtreamCategories(playlistId: string): {
lockedCategories?: XtreamBackupHiddenCategory[];
} {
const locked = this.parentalLock
.locksFor(playlistId)
.xtream.map((entry) => ({
categoryType: entry.categoryType,
xtreamId: entry.xtreamId,
}));
return locked.length > 0 ? { lockedCategories: locked } : {};
}
private optionalLockedStalkerCategories(playlistId: string): {
lockedCategories?: StalkerBackupLockedCategory[];
} {
const locked = this.parentalLock
.locksFor(playlistId)
.stalker.map((entry) => ({ ...entry }));
return locked.length > 0 ? { lockedCategories: locked } : {};
}
private optionalLockedGroupTitles(playlistId: string): {
lockedGroupTitles?: string[];
} {
// Exact titles: M3U locks match `channel.group.title` verbatim, so
// the trimming `uniqueStrings` would turn a lock on " Adult " into
// one on "Adult" and weaken the restored lock.
const locked = normalizeParentalLockGroupTitles(
this.parentalLock.locksFor(playlistId).m3u
);
return locked.length > 0 ? { lockedGroupTitles: locked } : {};
}
/**
* Parental locks travel next to the hidden state of each entry. Absent
* fields mean the archive predates the lock (or was written by a build
* that could not read it) and leave the playlist's locks alone.
*/
private async restoreParentalLocks(
playlistId: string,
entry: PlaylistBackupEntry,
isMerge: boolean
): Promise<void> {
// The stale-id check below and the merge base both read the store; an
// empty fail-closed snapshot would let a reused id keep a deleted
// playlist's locks once a later read recovers them.
if (!(await this.parentalLock.ensureLocksReadable())) {
throw new PlaylistBackupError(
`Restoring the parental locks for "${playlistId}" failed: the lock store could not be read.`
);
}
// A playlist created by this restore owns no locks yet: whatever the
// store holds under a reused id belongs to a deleted playlist (a
// cleanup that failed), and must not be inherited.
const current = isMerge
? this.parentalLock.locksFor(playlistId)
: { xtream: [], stalker: [], m3u: [] };
const staleOnNewId =
!isMerge &&
!isParentalLockPlaylistLocksEmpty(
this.parentalLock.locksFor(playlistId)
);
let next: ParentalLockPlaylistLocks | null = staleOnNewId
? current
: null;
if (entry.portalType === 'm3u') {
const titles = entry.userState.lockedGroupTitles;
if (Array.isArray(titles)) {
next = {
...current,
m3u: normalizeParentalLockGroupTitles(titles),
};
}
} else if (entry.portalType === 'xtream') {
const categories = entry.userState.lockedCategories;
if (Array.isArray(categories)) {
next = {
...current,
xtream: normalizeParentalLockXtreamCategories(categories),
};
}
} else if (Array.isArray(entry.userState.lockedCategories)) {
next = {
...current,
stalker: normalizeParentalLockStalkerCategories(
entry.userState.lockedCategories
),
};
}
// Taking a lock away needs an unlocked session when it commits. The
// PIN asked at the start may be stale by now (idle relock or "Lock
// now" during a long import): ask again if the session relocked.
// A restore that only adds locks is not asked.
if (
next &&
removesParentalLocks(
this.parentalLock.locksFor(playlistId),
next
) &&
!(await this.parentalLock.requestUnlock())
) {
throw new PlaylistBackupError(
`Restoring the parental locks for "${playlistId}" needs the parental PIN: the app locked again during the restore.`
);
}
if (
next &&
!(await this.parentalLock.replacePlaylistLocks(playlistId, next))
) {
throw new PlaylistBackupError(
`Restoring the parental locks for "${playlistId}" failed.`
);
}
}
private mapHiddenCategories(
categories: Array<{
hidden: boolean;
@@ -41,8 +41,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
const backup = await service.exportBackup();
const entry = backup.manifest
.playlists[0] as XtreamPlaylistBackupEntry;
const entry = backup.manifest.playlists[0] as XtreamPlaylistBackupEntry;
const expectedHiddenCategories = [
{ categoryType: 'live', xtreamId: 101 },
{ categoryType: 'movies', xtreamId: 201 },
@@ -92,9 +91,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
'xtream-1',
expect.objectContaining({
state: expect.objectContaining({
hiddenCategories: [
{ categoryType: 'live', xtreamId: 101 },
],
hiddenCategories: [{ categoryType: 'live', xtreamId: 101 }],
}),
}),
expect.any(Function)
@@ -107,11 +104,151 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
);
});
it('keeps the previous locks when the Xtream part of a merge fails', async () => {
const collaborators = createRestoreCollaborators();
const replacePlaylistLocks = jest.fn().mockResolvedValue(true);
const service = createPlaylistBackupService({
...collaborators,
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
requestUnlock: jest.fn().mockResolvedValue(true),
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks,
},
});
collaborators.databaseService.updateCategoryVisibility.mockRejectedValue(
new Error('SQLITE_BUSY')
);
const summary = await service.importBackup(
JSON.stringify(createXtreamManifest([]))
);
expect(summary.failed).toBe(1);
expect(replacePlaylistLocks).not.toHaveBeenCalled();
});
it('never lets a newly created playlist inherit stale locks under a reused id', async () => {
const collaborators = createRestoreCollaborators();
// Empty library: the restore CREATES the playlist (no merge match).
collaborators.playlistsService.getAllData.mockReturnValue(of([]));
const replacePlaylistLocks = jest.fn().mockResolvedValue(true);
const service = createPlaylistBackupService({
...collaborators,
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
requestUnlock: jest.fn().mockResolvedValue(true),
// A failed cleanup left locks under the id the restore reuses.
locksFor: jest.fn(() => ({
xtream: [{ categoryType: 'live', xtreamId: 1 }],
stalker: [],
m3u: [],
})),
replacePlaylistLocks,
},
});
const manifest = createXtreamManifest([]);
delete (
manifest.playlists[0].userState as { lockedCategories?: unknown }
).lockedCategories;
await service.importBackup(JSON.stringify(manifest));
expect(replacePlaylistLocks).toHaveBeenCalledWith(expect.any(String), {
xtream: [],
stalker: [],
m3u: [],
});
});
it('asks for the PIN before restoring lock lists and aborts when it is refused', async () => {
const collaborators = createRestoreCollaborators();
const requestUnlock = jest.fn().mockResolvedValue(false);
const replacePlaylistLocks = jest.fn().mockResolvedValue(true);
const service = createPlaylistBackupService({
...collaborators,
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
requestUnlock,
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks,
},
});
const manifest = createXtreamManifest([]);
(
manifest.playlists[0].userState as { lockedCategories?: unknown }
).lockedCategories = [];
await expect(
service.importBackup(JSON.stringify(manifest))
).rejects.toThrow(/parental PIN/);
expect(requestUnlock).toHaveBeenCalled();
expect(replacePlaylistLocks).not.toHaveBeenCalled();
expect(
collaborators.playlistsService.addPlaylist
).not.toHaveBeenCalled();
});
it('asks again before a merge replaces locks once the app relocked mid-import', async () => {
const collaborators = createRestoreCollaborators();
// Accepted at the start, refused after an idle relock or "Lock now"
// landed while the import was running.
const requestUnlock = jest
.fn()
.mockResolvedValueOnce(true)
.mockResolvedValue(false);
const replacePlaylistLocks = jest.fn().mockResolvedValue(true);
const service = createPlaylistBackupService({
...collaborators,
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
requestUnlock,
locksFor: jest.fn(() => ({
xtream: [{ categoryType: 'live', xtreamId: 1 }],
stalker: [],
m3u: [],
})),
replacePlaylistLocks,
},
});
const manifest = createXtreamManifest([]);
(
manifest.playlists[0].userState as { lockedCategories?: unknown }
).lockedCategories = [];
const summary = await service.importBackup(JSON.stringify(manifest));
expect(requestUnlock).toHaveBeenCalledTimes(2);
expect(replacePlaylistLocks).not.toHaveBeenCalled();
expect(summary).toEqual(
expect.objectContaining({ merged: 0, failed: 1 })
);
expect(summary.errors[0]).toMatch(/locked again/);
});
it('rejects a damaged parental lock list instead of erasing the persisted locks', async () => {
const collaborators = createRestoreCollaborators();
const service = createPlaylistBackupService(collaborators);
const manifest = createXtreamManifest([]);
(
manifest.playlists[0].userState as { lockedCategories?: unknown }
).lockedCategories = [{}];
await expect(
service.importBackup(JSON.stringify(manifest))
).rejects.toThrow(/invalid parental locks/);
expect(
collaborators.databaseService.updateCategoryVisibility
).not.toHaveBeenCalled();
});
it('rejects entries with missing user-state collections instead of wiping user data', async () => {
const collaborators = createRestoreCollaborators();
@@ -121,9 +258,8 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
// treated as an authoritative "empty" state: the merge path would
// unhide every category and delete favorites/recent/positions.
const manifest = createXtreamManifest([]);
delete (
manifest.playlists[0] as unknown as { userState?: unknown }
).userState;
delete (manifest.playlists[0] as unknown as { userState?: unknown })
.userState;
await expect(
service.importBackup(JSON.stringify(manifest))
@@ -20,6 +20,16 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsPortalConnectivityGuard).toBe(false);
});
it('offers the parental lock worker filter only with both bridge methods', () => {
const service = new RuntimeCapabilitiesService();
testWindow.electron = { setParentalLockState: jest.fn() };
expect(service.supportsParentalLockSqliteFilter).toBe(false);
testWindow.electron['dbSetCategoryLocks'] = jest.fn();
expect(service.supportsParentalLockSqliteFilter).toBe(true);
testWindow.electron = undefined;
expect(service.supportsParentalLockSqliteFilter).toBe(false);
});
it('reports browser PWA capabilities when the Electron bridge is absent', () => {
testWindow.electron = undefined;
@@ -180,6 +180,19 @@ export class RuntimeCapabilitiesService {
].every((methodName) => this.hasElectronMethod(methodName));
}
/**
* The worker-side parental lock filter: the lock state reaches the
* SQLite worker and the `categories.locked` index can be stamped. When
* the Xtream SQLite data source is used without it, the worker cannot
* withhold locked rows and the lock service fails closed instead.
*/
get supportsParentalLockSqliteFilter(): boolean {
return (
this.hasElectronMethod('setParentalLockState') &&
this.hasElectronMethod('dbSetCategoryLocks')
);
}
get supportsXtreamSqliteDataSource(): boolean {
return [
'dbGetPlaylist',
@@ -0,0 +1,115 @@
import {
DEFAULT_DASHBOARD_RAILS_SETTINGS,
DEFAULT_PARENTAL_LOCK_RELOCK_MINUTES,
DEFAULT_TMDB_SETTINGS,
Language,
Settings,
StartupBehavior,
StreamFormat,
Theme,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
/** Defaults and boot-time helpers of `SettingsStore`, split out for size. */
export const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: true,
playerAmbientMode: false,
playerUpNextRail: true,
fullscreenChannelPanel: true,
vodAutoFailover: false,
m3uVodDetails: true,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal',
updateChannel: 'stable',
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
vlcPlayerArguments: '',
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: 8765,
epgUrl: [],
downloadFolder: '',
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: '',
embeddedMpvAutoReconnect: true,
portalConnectivityGuard: true,
coverSize: 'medium',
showCoverTitles: true,
epgViewMode: 'timeline',
epgOffsetMinutes: 0,
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
preferUploadedEpgOverXtream: false,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
tmdb: DEFAULT_TMDB_SETTINGS,
parentalLockEnabled: false,
parentalLockRelockMinutes: DEFAULT_PARENTAL_LOCK_RELOCK_MINUTES,
};
/**
* Which half of the settings persistence round-trip failed, if any.
*
* Settings live in the renderer's IndexedDB, which can be unavailable for
* reasons the app cannot control (a second instance holding the Chromium
* storage lock, a corrupted profile, storage blocked by security software).
* Both failures used to be swallowed: `updateSettings` patches the in-memory
* state before persisting, so a failed write still looked applied until the
* next restart (issue #1156). Recording the failure lets the settings UI say
* so instead of pretending the change stuck.
*/
export type SettingsStorageFailure = 'load' | 'save';
export interface SettingsStorageState {
storageFailure: SettingsStorageFailure | null;
}
let embeddedMpvPrepareScheduled = false;
export function scheduleEmbeddedMpvPrepare(): void {
if (
embeddedMpvPrepareScheduled ||
typeof window === 'undefined' ||
!window.electron?.prepareEmbeddedMpv
) {
return;
}
embeddedMpvPrepareScheduled = true;
const prepare = () => {
void window.electron
.prepareEmbeddedMpv?.()
.then((support) => {
if (!support?.supported) {
embeddedMpvPrepareScheduled = false;
}
})
.catch((error) => {
embeddedMpvPrepareScheduled = false;
console.warn('Failed to prepare embedded MPV.', error);
});
};
const idleWindow = window as typeof window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
if (idleWindow.requestIdleCallback) {
idleWindow.requestIdleCallback(prepare, { timeout: 5000 });
} else {
window.setTimeout(prepare, 2000);
}
}
+33 -99
View File
@@ -32,109 +32,18 @@ import {
normalizeAppUpdateChannel,
normalizeEpgOffsetMinutes,
normalizeDashboardRailsSettings,
normalizeParentalLockRelockMinutes,
normalizeStartupWindowMode,
DEFAULT_PARENTAL_LOCK_RELOCK_MINUTES,
} from '@iptvnator/shared/interfaces';
const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: true,
playerAmbientMode: false,
playerUpNextRail: true,
fullscreenChannelPanel: true,
vodAutoFailover: false,
m3uVodDetails: true,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal',
updateChannel: 'stable',
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
vlcPlayerArguments: '',
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: 8765,
epgUrl: [],
downloadFolder: '',
recordingFolder: '',
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: '',
embeddedMpvAutoReconnect: true,
portalConnectivityGuard: true,
coverSize: 'medium',
showCoverTitles: true,
epgViewMode: 'timeline',
epgOffsetMinutes: 0,
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
preferUploadedEpgOverXtream: false,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
tmdb: DEFAULT_TMDB_SETTINGS,
};
import {
DEFAULT_SETTINGS,
scheduleEmbeddedMpvPrepare,
SettingsStorageState,
} from './settings-store.defaults';
/**
* Which half of the settings persistence round-trip failed, if any.
*
* Settings live in the renderer's IndexedDB, which can be unavailable for
* reasons the app cannot control (a second instance holding the Chromium
* storage lock, a corrupted profile, storage blocked by security software).
* Both failures used to be swallowed: `updateSettings` patches the in-memory
* state before persisting, so a failed write still looked applied until the
* next restart (issue #1156). Recording the failure lets the settings UI say
* so instead of pretending the change stuck.
*/
export type SettingsStorageFailure = 'load' | 'save';
interface SettingsStorageState {
storageFailure: SettingsStorageFailure | null;
}
let embeddedMpvPrepareScheduled = false;
function scheduleEmbeddedMpvPrepare(): void {
if (
embeddedMpvPrepareScheduled ||
typeof window === 'undefined' ||
!window.electron?.prepareEmbeddedMpv
) {
return;
}
embeddedMpvPrepareScheduled = true;
const prepare = () => {
void window.electron
.prepareEmbeddedMpv?.()
.then((support) => {
if (!support?.supported) {
embeddedMpvPrepareScheduled = false;
}
})
.catch((error) => {
embeddedMpvPrepareScheduled = false;
console.warn('Failed to prepare embedded MPV.', error);
});
};
const idleWindow = window as typeof window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions
) => number;
};
if (idleWindow.requestIdleCallback) {
idleWindow.requestIdleCallback(prepare, { timeout: 5000 });
} else {
window.setTimeout(prepare, 2000);
}
}
export type { SettingsStorageFailure } from './settings-store.defaults';
export const SettingsStore = signalStore(
{ providedIn: 'root' },
@@ -180,6 +89,12 @@ export const SettingsStore = signalStore(
dashboardRails: normalizeDashboardRailsSettings(
storedSettings.dashboardRails
),
parentalLockEnabled:
storedSettings.parentalLockEnabled === true,
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(
storedSettings.parentalLockRelockMinutes
),
});
void this.sanitizeEmbeddedMpvSelection().catch(
(error) => {
@@ -232,6 +147,20 @@ export const SettingsStore = signalStore(
),
}
: {}),
...(settings.parentalLockEnabled !== undefined
? {
parentalLockEnabled:
settings.parentalLockEnabled === true,
}
: {}),
...(settings.parentalLockRelockMinutes !== undefined
? {
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(
settings.parentalLockRelockMinutes
),
}
: {}),
});
// Save the complete settings object, not just the partial update
const completeSettings = this.getSettings();
@@ -344,6 +273,11 @@ export const SettingsStore = signalStore(
store.trustedInsecureTlsHosts?.() ??
DEFAULT_SETTINGS.trustedInsecureTlsHosts,
tmdb: store.tmdb?.() ?? DEFAULT_SETTINGS.tmdb,
parentalLockEnabled: store.parentalLockEnabled?.() === true,
parentalLockRelockMinutes:
normalizeParentalLockRelockMinutes(
store.parentalLockRelockMinutes?.()
),
};
},
@@ -174,6 +174,7 @@ const CREATE_TABLE_STATEMENTS = [
type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')),
xtream_id INTEGER NOT NULL,
hidden INTEGER DEFAULT 0,
locked INTEGER DEFAULT 0,
UNIQUE(playlist_id, type, xtream_id),
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`,
@@ -381,6 +382,8 @@ const CREATE_TABLE_STATEMENTS = [
const COLUMN_MIGRATION_STATEMENTS = [
// v1.0.0 -> v1.1.0: Add hidden column to categories for category management
`ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0`,
// Parental lock: per-category lock index (issue #285)
`ALTER TABLE categories ADD COLUMN locked INTEGER DEFAULT 0`,
// v1.1.0 -> v1.2.0: Add playlist metadata/payload columns for M3U + unified playlist persistence
`ALTER TABLE playlists ADD COLUMN portal_url TEXT`,
`ALTER TABLE playlists ADD COLUMN count INTEGER`,
+5
View File
@@ -75,6 +75,11 @@ export const categories = sqliteTable(
type: text('type', { enum: ['live', 'movies', 'series'] }).notNull(),
xtreamId: integer('xtream_id').notNull(),
hidden: integer('hidden', { mode: 'boolean' }).default(false),
// Parental lock index: derived from the renderer's lock store (the
// source of truth, keyed by provider category id) and re-stamped by
// DB_SET_CATEGORY_LOCKS. While the lock is active every content read
// in the worker filters on it — see parental-lock-state.ts.
locked: integer('locked', { mode: 'boolean' }).default(false),
},
(table) => ({
playlistIdx: index('idx_categories_playlist').on(table.playlistId),
+3
View File
@@ -27,6 +27,9 @@ export * from './lib/ipc-command.class';
export * from './lib/ipc-commands';
export * from './lib/language.enum';
export * from './lib/m3u-favorite-channel.interface';
export * from './lib/parental-lock.util';
export * from './lib/parental-lock-pin.util';
export * from './lib/parental-lock-pin-throttle.util';
export * from './lib/parsed-playlist.interface';
export * from './lib/performance-phase.interface';
export * from './lib/playback-position.interface';
@@ -497,6 +497,8 @@ export interface ElectronBridgeCategoryRow {
type: 'movies' | 'live' | 'series';
xtream_id: number;
hidden: boolean;
/** Parental lock index, stamped from the renderer's lock store. */
locked: boolean;
}
export interface ElectronBridgeXtreamContent {
@@ -773,6 +775,14 @@ export interface ElectronBridgeApi {
* leave the display pinned awake.
*/
setPlaybackKeepAwake: (active: boolean) => Promise<void>;
/**
* Parental lock enforcement for the SQLite worker: `true` withholds
* every row in a locked category from content reads, `false` returns
* them. Reset to the mirrored `parentalLockEnabled` setting when the
* renderer reloads or dies, so a crashed page can never leave the
* library unlocked.
*/
setParentalLockState: (active: boolean) => Promise<void>;
fetchPlaylistByUrl: (
url: string,
title?: string,
@@ -1016,7 +1026,8 @@ export interface ElectronBridgeApi {
playlistId: string,
categories: XtreamCategory[],
type: string,
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
lockedCategoryXtreamIds?: number[]
) => Promise<ElectronBridgeResult>;
dbGetAllCategories: (
playlistId: string,
@@ -1026,6 +1037,17 @@ export interface ElectronBridgeApi {
categoryIds: number[],
hidden: boolean
) => Promise<ElectronBridgeResult>;
/**
* Re-stamps `categories.locked` for one playlist and type from the given
* provider category ids: rows in the list become locked, every other row
* of that playlist/type unlocked. Idempotent, so the renderer can replay
* its lock store after a refresh or a backup restore.
*/
dbSetCategoryLocks: (
playlistId: string,
type: string,
lockedXtreamIds: number[]
) => Promise<ElectronBridgeResult>;
dbHasContent: (playlistId: string, type: string) => Promise<boolean>;
dbGetContent: (
playlistId: string,
@@ -124,6 +124,12 @@ export const REMOTE_CONTROL_CHANGE_CHANNEL = 'REMOTE_CONTROL_CHANGE_CHANNEL';
// the main process to hold a powerSaveBlocker so the screen stays awake
export const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE';
// Parental lock: the renderer tells the main process whether locked
// categories must currently be withheld (feature on and no PIN entered), so
// the SQLite worker can filter every content read. Reset to the mirrored
// `parentalLockEnabled` setting when the renderer reloads or dies.
export const PARENTAL_LOCK_SET_STATE = 'PARENTAL_LOCK:SET_STATE';
// Window controls (custom title bar on Windows/Linux)
export const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
export const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
@@ -0,0 +1,34 @@
import {
createParentalLockPinThrottle,
PARENTAL_LOCK_PIN_COOLDOWN_MS,
PARENTAL_LOCK_PIN_MAX_ATTEMPTS,
} from './parental-lock-pin-throttle.util';
describe('createParentalLockPinThrottle', () => {
it('starts a cooldown after the maximum failures and ends it on time', () => {
let clock = 1_000;
const throttle = createParentalLockPinThrottle(() => clock);
for (let i = 1; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) {
expect(throttle.recordFailure()).toBe(false);
}
expect(throttle.cooldownUntil()).toBe(0);
expect(throttle.recordFailure()).toBe(true);
expect(throttle.cooldownUntil()).toBe(
1_000 + PARENTAL_LOCK_PIN_COOLDOWN_MS
);
clock += PARENTAL_LOCK_PIN_COOLDOWN_MS;
expect(throttle.cooldownUntil()).toBe(0);
});
it('clears the count and the cooldown after the right PIN', () => {
const throttle = createParentalLockPinThrottle(() => 0);
for (let i = 1; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) {
throttle.recordFailure();
}
throttle.recordSuccess();
expect(throttle.recordFailure()).toBe(false);
expect(throttle.cooldownUntil()).toBe(0);
});
});
@@ -0,0 +1,40 @@
/** Failed PIN attempts before the prompt pauses for the cooldown. */
export const PARENTAL_LOCK_PIN_MAX_ATTEMPTS = 5;
export const PARENTAL_LOCK_PIN_COOLDOWN_MS = 30_000;
/**
* Wrong-PIN bookkeeping that outlives a single PIN dialog. Kept by the lock
* service and handed to every unlock prompt, so dismissing the dialog during
* a cooldown and opening it again neither resets the count nor the pause.
*/
export interface ParentalLockPinThrottle {
/** Epoch milliseconds until which no PIN may be tried; 0 when none. */
cooldownUntil(): number;
/** Records a wrong PIN; true when this failure starts a cooldown. */
recordFailure(): boolean;
/** Clears the count after the right PIN. */
recordSuccess(): void;
}
export function createParentalLockPinThrottle(
now: () => number = Date.now
): ParentalLockPinThrottle {
let failedAttempts = 0;
let cooldownUntil = 0;
return {
cooldownUntil: () => (cooldownUntil > now() ? cooldownUntil : 0),
recordFailure: () => {
failedAttempts += 1;
if (failedAttempts < PARENTAL_LOCK_PIN_MAX_ATTEMPTS) {
return false;
}
failedAttempts = 0;
cooldownUntil = now() + PARENTAL_LOCK_PIN_COOLDOWN_MS;
return true;
},
recordSuccess: () => {
failedAttempts = 0;
cooldownUntil = 0;
},
};
}
@@ -0,0 +1,57 @@
import { webcrypto } from 'node:crypto';
import {
hashParentalLockPin,
isValidParentalLockPin,
verifyParentalLockPin,
} from './parental-lock-pin.util';
describe('parental-lock-pin.util', () => {
beforeAll(() => {
// The lib compiles for the browser; give Node's WebCrypto the same
// global shape the renderer has.
if (!globalThis.crypto?.subtle) {
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: webcrypto,
});
}
});
it('accepts 4 to 8 digits only', () => {
expect(isValidParentalLockPin('1234')).toBe(true);
expect(isValidParentalLockPin('12345678')).toBe(true);
expect(isValidParentalLockPin('123')).toBe(false);
expect(isValidParentalLockPin('123456789')).toBe(false);
expect(isValidParentalLockPin('12a4')).toBe(false);
expect(isValidParentalLockPin(1234)).toBe(false);
});
it('hashes with a fresh salt and verifies the right PIN only', async () => {
const first = await hashParentalLockPin('2468');
const second = await hashParentalLockPin('2468');
expect(first).not.toEqual(second);
expect(first.startsWith('v1$')).toBe(true);
expect(first).not.toContain('2468');
await expect(verifyParentalLockPin('2468', first)).resolves.toBe(true);
await expect(verifyParentalLockPin('2468', second)).resolves.toBe(true);
await expect(verifyParentalLockPin('2469', first)).resolves.toBe(false);
});
it('never verifies against a malformed or missing hash', async () => {
await expect(verifyParentalLockPin('1234', null)).resolves.toBe(false);
await expect(verifyParentalLockPin('1234', 'v0$x$y$z')).resolves.toBe(
false
);
await expect(
verifyParentalLockPin('1234', 'v1$abc$!!$??')
).resolves.toBe(false);
await expect(
verifyParentalLockPin('12', 'v1$1$AA==$AA==')
).resolves.toBe(false);
});
it('refuses to hash an invalid PIN', async () => {
await expect(hashParentalLockPin('12')).rejects.toThrow();
});
});
@@ -0,0 +1,163 @@
/**
* PIN hashing for the parental lock.
*
* The PIN is a child lock, not a security boundary: the stored hash lives in a
* user-readable file. It is still never stored in clear — PBKDF2-SHA256 with
* a random salt through WebCrypto, which exists in both the Electron
* renderer and the browser, so Electron and PWA share one implementation.
*
* Stored format: `v1$<iterations>$<salt base64>$<hash base64>`.
*/
export const PARENTAL_LOCK_PIN_MIN_LENGTH = 4;
export const PARENTAL_LOCK_PIN_MAX_LENGTH = 8;
const PIN_HASH_VERSION = 'v1';
const PIN_HASH_ITERATIONS = 100_000;
const PIN_HASH_SALT_BYTES = 16;
const PIN_HASH_LENGTH_BITS = 256;
export function isValidParentalLockPin(pin: unknown): pin is string {
return (
typeof pin === 'string' &&
pin.length >= PARENTAL_LOCK_PIN_MIN_LENGTH &&
pin.length <= PARENTAL_LOCK_PIN_MAX_LENGTH &&
/^[0-9]+$/.test(pin)
);
}
/**
* Structural view of the WebCrypto surface this file needs. Named DOM types
* (`SubtleCrypto`, `BufferSource`) are unavailable to the Node build of the
* web backend, which compiles this shared library as well.
*/
interface WebCryptoSubtleLike {
importKey(
format: 'raw',
keyData: Uint8Array,
algorithm: 'PBKDF2',
extractable: boolean,
keyUsages: ['deriveBits']
): Promise<unknown>;
deriveBits(
algorithm: {
name: 'PBKDF2';
hash: 'SHA-256';
salt: Uint8Array;
iterations: number;
},
baseKey: unknown,
length: number
): Promise<ArrayBuffer>;
}
interface WebCryptoLike {
subtle?: WebCryptoSubtleLike;
getRandomValues?<T extends Uint8Array>(array: T): T;
}
function getWebCrypto(): Required<WebCryptoLike> {
const cryptoApi = (globalThis as { crypto?: WebCryptoLike }).crypto;
if (!cryptoApi?.subtle || !cryptoApi.getRandomValues) {
throw new Error('WebCrypto is unavailable in this runtime.');
}
return cryptoApi as Required<WebCryptoLike>;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
async function derivePinHash(
pin: string,
salt: Uint8Array,
iterations: number
): Promise<Uint8Array> {
const subtle = getWebCrypto().subtle;
const keyMaterial = await subtle.importKey(
'raw',
new TextEncoder().encode(pin),
'PBKDF2',
false,
['deriveBits']
);
const bits = await subtle.deriveBits(
{
name: 'PBKDF2',
hash: 'SHA-256',
salt,
iterations,
},
keyMaterial,
PIN_HASH_LENGTH_BITS
);
return new Uint8Array(bits);
}
export async function hashParentalLockPin(pin: string): Promise<string> {
if (!isValidParentalLockPin(pin)) {
throw new Error('PIN must be 4 to 8 digits.');
}
const salt = new Uint8Array(PIN_HASH_SALT_BYTES);
getWebCrypto().getRandomValues(salt);
const hash = await derivePinHash(pin, salt, PIN_HASH_ITERATIONS);
return [
PIN_HASH_VERSION,
String(PIN_HASH_ITERATIONS),
bytesToBase64(salt),
bytesToBase64(hash),
].join('$');
}
function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) {
return false;
}
let difference = 0;
for (let index = 0; index < left.length; index++) {
difference |= left[index] ^ right[index];
}
return difference === 0;
}
/**
* Verifies a PIN against a stored hash. A malformed stored value never
* verifies — it can only be replaced by setting a new PIN.
*/
export async function verifyParentalLockPin(
pin: string,
storedHash: string | null | undefined
): Promise<boolean> {
if (!isValidParentalLockPin(pin) || typeof storedHash !== 'string') {
return false;
}
const parts = storedHash.split('$');
if (parts.length !== 4 || parts[0] !== PIN_HASH_VERSION) {
return false;
}
const iterations = Number(parts[1]);
if (!Number.isInteger(iterations) || iterations <= 0) {
return false;
}
try {
const salt = base64ToBytes(parts[2]);
const expected = base64ToBytes(parts[3]);
const actual = await derivePinHash(pin, salt, iterations);
return constantTimeEqual(actual, expected);
} catch {
return false;
}
}
@@ -0,0 +1,184 @@
import {
isParentalLockPlaylistLocksEmpty,
normalizeParentalLockGroupTitles,
isWellFormedParentalLockStore,
lockedStalkerCategoryIds,
lockedXtreamCategoryIds,
normalizeParentalLockPlaylistLocks,
normalizeParentalLockStore,
toParentalLockStalkerCategoryType,
toParentalLockXtreamCategoryType,
} from './parental-lock.util';
describe('parental-lock.util', () => {
describe('normalizeParentalLockPlaylistLocks', () => {
it('keeps well-formed entries and drops junk, duplicates and the All pseudo-genre', () => {
const locks = normalizeParentalLockPlaylistLocks({
xtream: [
{ categoryType: 'live', xtreamId: 7 },
{ categoryType: 'live', xtreamId: '7' },
{ categoryType: 'movies', xtreamId: 'abc' },
{ categoryType: 'unknown', xtreamId: 1 },
null,
],
stalker: [
{ categoryType: 'itv', categoryId: 12 },
{ categoryType: 'itv', categoryId: '12' },
{ categoryType: 'vod', categoryId: '*' },
{ categoryType: 'radio', categoryId: '' },
],
m3u: ['Adult', 'Adult', 42, 'News'],
});
expect(locks).toEqual({
xtream: [{ categoryType: 'live', xtreamId: 7 }],
stalker: [{ categoryType: 'itv', categoryId: '12' }],
m3u: ['Adult', 'News'],
});
});
it('returns empty locks for non-objects', () => {
expect(normalizeParentalLockPlaylistLocks(null)).toEqual({
xtream: [],
stalker: [],
m3u: [],
});
expect(normalizeParentalLockPlaylistLocks('x')).toEqual({
xtream: [],
stalker: [],
m3u: [],
});
});
});
describe('normalizeParentalLockStore', () => {
it('drops playlists that lock nothing', () => {
const store = normalizeParentalLockStore({
'p-1': { m3u: ['XXX'] },
'p-2': { xtream: [], stalker: [], m3u: [] },
'': { m3u: ['ignored'] },
'p-3': 'junk',
});
expect(Object.keys(store)).toEqual(['p-1']);
expect(isParentalLockPlaylistLocksEmpty(store['p-1'])).toBe(false);
});
it('returns an empty store for anything that is not an object', () => {
expect(normalizeParentalLockStore(undefined)).toEqual({});
expect(normalizeParentalLockStore([])).toEqual({});
});
});
describe('id lookups', () => {
const locks = normalizeParentalLockPlaylistLocks({
xtream: [
{ categoryType: 'live', xtreamId: 1 },
{ categoryType: 'movies', xtreamId: 2 },
],
stalker: [
{ categoryType: 'itv', categoryId: '10' },
{ categoryType: 'series', categoryId: '11' },
],
});
it('filters Xtream ids by category type', () => {
expect(lockedXtreamCategoryIds(locks, 'live')).toEqual([1]);
expect(lockedXtreamCategoryIds(locks, 'series')).toEqual([]);
expect(lockedXtreamCategoryIds(undefined, 'live')).toEqual([]);
});
it('filters Stalker ids by section', () => {
expect(lockedStalkerCategoryIds(locks, 'itv')).toEqual(['10']);
expect(lockedStalkerCategoryIds(locks, 'vod')).toEqual([]);
});
});
describe('section mapping', () => {
it('maps Xtream route sections to DB category types', () => {
expect(toParentalLockXtreamCategoryType('live')).toBe('live');
expect(toParentalLockXtreamCategoryType('vod')).toBe('movies');
expect(toParentalLockXtreamCategoryType('movies')).toBe('movies');
expect(toParentalLockXtreamCategoryType('series')).toBe('series');
expect(toParentalLockXtreamCategoryType('search')).toBeNull();
});
it('accepts only Stalker sections with a genre list', () => {
expect(toParentalLockStalkerCategoryType('itv')).toBe('itv');
expect(toParentalLockStalkerCategoryType('radio')).toBe('radio');
expect(toParentalLockStalkerCategoryType('favorites')).toBeNull();
expect(toParentalLockStalkerCategoryType(undefined)).toBeNull();
});
});
});
describe('isWellFormedParentalLockStore', () => {
it('accepts what writeLocks produces, lists present even when empty', () => {
expect(isWellFormedParentalLockStore({})).toBe(true);
expect(
isWellFormedParentalLockStore({
p: {
xtream: [{ categoryType: 'live', xtreamId: 7 }],
stalker: [{ categoryType: 'itv', categoryId: '9' }],
m3u: ['Adult'],
},
q: { xtream: [], stalker: [], m3u: [] },
})
).toBe(true);
});
it.each([
['a non-object root', []],
['a non-object playlist entry', { p: 'corrupt' }],
['a playlist entry missing a list', { p: { xtream: [], m3u: [] } }],
['an empty playlist entry', { p: {} }],
[
'a non-list lock field',
{ p: { xtream: 'corrupt', stalker: [], m3u: [] } },
],
[
'a non-string group title',
{ p: { xtream: [], stalker: [], m3u: [1] } },
],
[
'an unknown category type',
{
p: {
xtream: [{ categoryType: 'x', xtreamId: 1 }],
stalker: [],
m3u: [],
},
},
],
[
'a non-numeric xtream id',
{
p: {
xtream: [{ categoryType: 'live', xtreamId: 'nope' }],
stalker: [],
m3u: [],
},
},
],
[
'the Stalker "all" pseudo id',
{
p: {
xtream: [],
stalker: [{ categoryType: 'itv', categoryId: '*' }],
m3u: [],
},
},
],
])('rejects %s', (_, value) => {
expect(isWellFormedParentalLockStore(value)).toBe(false);
});
});
describe('normalizeParentalLockGroupTitles', () => {
it('deduplicates exactly, keeping surrounding whitespace', () => {
expect(
normalizeParentalLockGroupTitles([' Adult ', ' Adult ', 'Adult', 7])
).toEqual([' Adult ', 'Adult']);
});
});
@@ -0,0 +1,323 @@
/**
* Parental lock contracts shared by the renderer service, the Electron DB
* worker (which stamps Xtream locks onto `categories.locked`) and the
* playlist backup format.
*
* The unit of locking is the CATEGORY: an Xtream category (by provider
* `category_id`, stable across refreshes), a Stalker genre (by portal id) or
* an M3U `group-title`. The renderer keeps every lock in one store keyed by
* playlist id; the SQLite column is only a query index derived from it.
*/
export type ParentalLockPortalType = 'xtream' | 'stalker' | 'm3u';
/** Xtream category table types (`categories.type`). */
export type ParentalLockXtreamCategoryType = 'live' | 'movies' | 'series';
/** Stalker content sections that carry their own genre list. */
export type ParentalLockStalkerCategoryType =
'itv' | 'vod' | 'series' | 'radio';
export interface ParentalLockXtreamCategory {
categoryType: ParentalLockXtreamCategoryType;
xtreamId: number;
}
export interface ParentalLockStalkerCategory {
categoryType: ParentalLockStalkerCategoryType;
categoryId: string;
}
export interface ParentalLockPlaylistLocks {
xtream: ParentalLockXtreamCategory[];
stalker: ParentalLockStalkerCategory[];
/** Locked M3U `group-title` values, exact match. */
m3u: string[];
}
/** Every locked category, keyed by playlist id. */
export type ParentalLockStore = Record<string, ParentalLockPlaylistLocks>;
/** Storage key for the lock store (Electron `app_state`, PWA localStorage). */
export const PARENTAL_LOCK_STORE_KEY = 'parental-lock:locks';
/** Storage key for the hashed PIN — deliberately outside `Settings`. */
export const PARENTAL_LOCK_PIN_KEY = 'parental-lock:pin';
export const PARENTAL_LOCK_XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] =
['live', 'movies', 'series'];
export const PARENTAL_LOCK_STALKER_CATEGORY_TYPES: readonly ParentalLockStalkerCategoryType[] =
['itv', 'vod', 'series', 'radio'];
/**
* A "withheld categories" set that withholds EVERY category. Used while the
* lock is active but the lock store could not be read: consumers keep their
* `size`/`has` checks and simply find nothing they may show.
*/
export const ALL_CATEGORIES_WITHHELD: ReadonlySet<string> = Object.freeze(
new (class extends Set<string> {
override has(): boolean {
return true;
}
override get size(): number {
return 1;
}
})()
);
export function createEmptyParentalLockPlaylistLocks(): ParentalLockPlaylistLocks {
return { xtream: [], stalker: [], m3u: [] };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function toArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function normalizeNumericId(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function normalizeParentalLockXtreamCategories(
value: unknown
): ParentalLockXtreamCategory[] {
const seen = new Set<string>();
const result: ParentalLockXtreamCategory[] = [];
for (const item of toArray(value)) {
if (!isRecord(item)) {
continue;
}
const categoryType = item[
'categoryType'
] as ParentalLockXtreamCategoryType;
const xtreamId = normalizeNumericId(item['xtreamId']);
if (
xtreamId === null ||
!PARENTAL_LOCK_XTREAM_CATEGORY_TYPES.includes(categoryType)
) {
continue;
}
const key = `${categoryType}:${xtreamId}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push({ categoryType, xtreamId });
}
return result;
}
export function normalizeParentalLockStalkerCategories(
value: unknown
): ParentalLockStalkerCategory[] {
const seen = new Set<string>();
const result: ParentalLockStalkerCategory[] = [];
for (const item of toArray(value)) {
if (!isRecord(item)) {
continue;
}
const categoryType = item[
'categoryType'
] as ParentalLockStalkerCategoryType;
const rawId = item['categoryId'];
const categoryId =
typeof rawId === 'string'
? rawId.trim()
: typeof rawId === 'number' && Number.isFinite(rawId)
? String(rawId)
: '';
if (
!categoryId ||
categoryId === '*' ||
!PARENTAL_LOCK_STALKER_CATEGORY_TYPES.includes(categoryType)
) {
continue;
}
const key = `${categoryType}:${categoryId}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push({ categoryType, categoryId });
}
return result;
}
/** A list whose every entry the matching normalizer would KEEP. */
export function isWellFormedParentalLockXtreamCategories(
value: unknown
): boolean {
return (
Array.isArray(value) &&
value.every(
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_XTREAM_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockXtreamCategoryType
) &&
normalizeNumericId(entry['xtreamId']) !== null
)
);
}
export function isWellFormedParentalLockStalkerCategories(
value: unknown
): boolean {
return (
Array.isArray(value) &&
value.every(
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_STALKER_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockStalkerCategoryType
) &&
((typeof entry['categoryId'] === 'string' &&
entry['categoryId'].trim() !== '' &&
entry['categoryId'].trim() !== '*') ||
(typeof entry['categoryId'] === 'number' &&
Number.isFinite(entry['categoryId'])))
)
);
}
export function isWellFormedParentalLockGroupTitles(value: unknown): boolean {
return (
Array.isArray(value) &&
value.every((entry) => typeof entry === 'string')
);
}
/**
* Whether a persisted store has the shape `writeLocks` produces: every
* playlist entry an object whose `xtream`/`stalker`/`m3u` lists are all
* present (a missing list is corruption, not "empty") and hold only
* entries the normalizers accept. The normalizers DROP what they do not
* understand; on a persisted store (and on a backup's lock lists, which a
* restore treats as authoritative) that would turn corruption into
* "nothing is locked", so such data is rejected instead.
*/
export function isWellFormedParentalLockStore(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return Object.values(value).every(
(locks) =>
isRecord(locks) &&
isWellFormedParentalLockXtreamCategories(locks['xtream']) &&
isWellFormedParentalLockStalkerCategories(locks['stalker']) &&
isWellFormedParentalLockGroupTitles(locks['m3u'])
);
}
export function normalizeParentalLockGroupTitles(value: unknown): string[] {
const seen = new Set<string>();
for (const item of toArray(value)) {
if (typeof item === 'string') {
seen.add(item);
}
}
return [...seen];
}
export function normalizeParentalLockPlaylistLocks(
value: unknown
): ParentalLockPlaylistLocks {
if (!isRecord(value)) {
return createEmptyParentalLockPlaylistLocks();
}
return {
xtream: normalizeParentalLockXtreamCategories(value['xtream']),
stalker: normalizeParentalLockStalkerCategories(value['stalker']),
m3u: normalizeParentalLockGroupTitles(value['m3u']),
};
}
export function isParentalLockPlaylistLocksEmpty(
locks: ParentalLockPlaylistLocks
): boolean {
return (
locks.xtream.length === 0 &&
locks.stalker.length === 0 &&
locks.m3u.length === 0
);
}
/**
* Normalizes a persisted or user-supplied lock store. Entries that lock
* nothing are dropped so the store never grows with empty playlists.
*/
export function normalizeParentalLockStore(value: unknown): ParentalLockStore {
const store: ParentalLockStore = {};
if (!isRecord(value)) {
return store;
}
for (const [playlistId, locks] of Object.entries(value)) {
if (!playlistId) {
continue;
}
const normalized = normalizeParentalLockPlaylistLocks(locks);
if (!isParentalLockPlaylistLocksEmpty(normalized)) {
store[playlistId] = normalized;
}
}
return store;
}
export function lockedXtreamCategoryIds(
locks: ParentalLockPlaylistLocks | undefined,
categoryType: ParentalLockXtreamCategoryType
): number[] {
return (locks?.xtream ?? [])
.filter((entry) => entry.categoryType === categoryType)
.map((entry) => entry.xtreamId);
}
export function lockedStalkerCategoryIds(
locks: ParentalLockPlaylistLocks | undefined,
categoryType: ParentalLockStalkerCategoryType
): string[] {
return (locks?.stalker ?? [])
.filter((entry) => entry.categoryType === categoryType)
.map((entry) => entry.categoryId);
}
/** Maps a Stalker section id to the genre list it locks against. */
export function toParentalLockStalkerCategoryType(
section: string | null | undefined
): ParentalLockStalkerCategoryType | null {
switch (section) {
case 'itv':
case 'vod':
case 'series':
case 'radio':
return section;
default:
return null;
}
}
/** Maps an Xtream route section (`live`/`vod`/`series`) to the DB category type. */
export function toParentalLockXtreamCategoryType(
section: string | null | undefined
): ParentalLockXtreamCategoryType | null {
switch (section) {
case 'live':
return 'live';
case 'vod':
case 'movies':
return 'movies';
case 'series':
return 'series';
default:
return null;
}
}
@@ -37,6 +37,12 @@ export interface M3uPlaylistBackupEntry extends PlaylistBackupBaseEntry {
favorites: string[];
recentlyViewed: M3uRecentlyViewedItem[];
hiddenGroupTitles: string[];
/**
* Parental-lock group titles. Optional: absent in archives written
* before the lock existed, and absence means "no opinion" — restore
* leaves the playlist's existing locks untouched.
*/
lockedGroupTitles?: string[];
};
}
@@ -77,6 +83,12 @@ export interface XtreamBackupUserState {
playbackPositions: PlaybackPositionData[];
/** Optional: absent in archives written before multi-source existed. */
sourcePins?: XtreamBackupSourcePin[];
/**
* Parental-lock categories, same shape as `hiddenCategories`. Optional
* like `sourcePins`: absence means the archive predates the lock and
* restore keeps the playlist's existing locks.
*/
lockedCategories?: XtreamBackupHiddenCategory[];
}
export interface XtreamPlaylistBackupEntry extends PlaylistBackupBaseEntry {
@@ -109,9 +121,16 @@ export interface StalkerPlaylistBackupEntry extends PlaylistBackupBaseEntry {
userState: {
favorites: StalkerPortalItem[];
recentlyViewed: StalkerPortalItem[];
/** Parental-lock genres; optional with the same absence rule as Xtream. */
lockedCategories?: StalkerBackupLockedCategory[];
};
}
export interface StalkerBackupLockedCategory {
categoryType: 'itv' | 'vod' | 'series' | 'radio';
categoryId: string;
}
export type PlaylistBackupEntry =
| M3uPlaylistBackupEntry
| XtreamPlaylistBackupEntry
@@ -297,4 +297,36 @@ export interface Settings {
* Disabled by default because enrichment sends content titles to TMDB.
*/
tmdb?: TmdbSettings;
/**
* Parental lock: locked categories disappear from the whole app until
* the PIN is entered. Off by default; may only be on while a PIN exists
* (the hash lives outside `Settings`, see `parental-lock-pin.util.ts`).
* Mirrored into the main-process config so the SQLite worker filters
* before the renderer has announced its lock state.
*/
parentalLockEnabled?: boolean;
/**
* Minutes without user interaction after which the app locks itself
* again; `0` keeps it unlocked until the next restart. Active playback
* counts as interaction. Default 15.
*/
parentalLockRelockMinutes?: ParentalLockRelockMinutes;
}
export type ParentalLockRelockMinutes = 0 | 5 | 15 | 30 | 60;
export const PARENTAL_LOCK_RELOCK_MINUTES_OPTIONS: readonly ParentalLockRelockMinutes[] =
[0, 5, 15, 30, 60];
export const DEFAULT_PARENTAL_LOCK_RELOCK_MINUTES: ParentalLockRelockMinutes = 15;
/** Collapses anything outside the offered choices to the default. */
export function normalizeParentalLockRelockMinutes(
value: unknown
): ParentalLockRelockMinutes {
return PARENTAL_LOCK_RELOCK_MINUTES_OPTIONS.includes(
value as ParentalLockRelockMinutes
)
? (value as ParentalLockRelockMinutes)
: DEFAULT_PARENTAL_LOCK_RELOCK_MINUTES;
}
+2
View File
@@ -15,6 +15,7 @@ export * from './lib/portal-detail-shell/detail-template.directives';
export * from './lib/portal-detail-shell/portal-detail-shell.component';
export * from './lib/view-in-portal-action/view-in-portal-action.component';
export * from './lib/view-in-portal-action/view-in-portal-handoff.token';
export * from './lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component';
export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/resizable/resizable.directive';
export * from './lib/season-container/season-container.component';
@@ -31,3 +32,4 @@ export * from './lib/watched-badge/watched-badge.component';
export * from './lib/window-controls/window-controls.component';
export * from './lib/channel-scroll-focus/channel-scroll-focus.directive';
export * from './lib/category-lock-menu/category-lock-menu.component';
@@ -0,0 +1,49 @@
import { Component, viewChild } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { CategoryLockMenuComponent } from './category-lock-menu.component';
@Component({
imports: [CategoryLockMenuComponent],
template: `<app-category-lock-menu
(toggleRequested)="requested.push($event)"
/>`,
})
class HostComponent {
readonly menu = viewChild.required(CategoryLockMenuComponent);
readonly requested: boolean[] = [];
}
describe('CategoryLockMenuComponent', () => {
it('opens at the pointer and requests the opposite lock state', async () => {
await TestBed.configureTestingModule({
imports: [
HostComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const host = fixture.componentInstance;
const event = new MouseEvent('contextmenu', {
clientX: 40,
clientY: 60,
cancelable: true,
});
host.menu().open(event, true);
await Promise.resolve();
fixture.detectChanges();
expect(event.defaultPrevented).toBe(true);
expect(host.menu().position()).toEqual({ x: '40px', y: '60px' });
const item = document.querySelector<HTMLButtonElement>(
'[data-test-id="category-lock-menu-toggle"]'
);
expect(item?.textContent).toContain('PARENTAL_LOCK.UNLOCK_CATEGORY');
item?.click();
expect(host.requested).toEqual([false]);
});
});
@@ -0,0 +1,85 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
/**
* Right-click accelerator of the parental lock: one positioned context menu
* with a single "Lock … / Unlock …" item. Hosts (the portal category rail,
* the M3U groups rail) call `open()` from a `contextmenu` event and persist
* the emitted target state themselves — the menu knows nothing about
* playlists or lock stores. Bulk actions and the full list stay in the
* "Manage categories" dialog; this only shortens the single-category case.
*/
@Component({
selector: 'app-category-lock-menu',
imports: [MatIconModule, MatMenuModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
aria-hidden="true"
class="category-lock-menu__anchor"
[style.left]="position().x"
[style.top]="position().y"
[matMenuTriggerFor]="menu"
#trigger="matMenuTrigger"
></div>
<mat-menu #menu="matMenu">
<button
mat-menu-item
type="button"
data-test-id="category-lock-menu-toggle"
(click)="toggleRequested.emit(!locked())"
>
<mat-icon>{{ locked() ? 'lock_open' : 'lock' }}</mat-icon>
<span>{{
(locked() ? unlockLabelKey() : lockLabelKey()) | translate
}}</span>
</button>
</mat-menu>
`,
styles: `
:host {
display: contents;
}
.category-lock-menu__anchor {
position: fixed;
width: 0;
height: 0;
pointer-events: none;
}
`,
})
export class CategoryLockMenuComponent {
readonly lockLabelKey = input('PARENTAL_LOCK.LOCK_CATEGORY');
readonly unlockLabelKey = input('PARENTAL_LOCK.UNLOCK_CATEGORY');
/** The desired state: `true` = lock the row, `false` = unlock it. */
readonly toggleRequested = output<boolean>();
readonly locked = signal(false);
readonly position = signal({ x: '0px', y: '0px' });
private readonly trigger = viewChild.required<MatMenuTrigger>('trigger');
/** Opens the menu at the pointer for a row whose lock state is `locked`. */
open(event: MouseEvent, locked: boolean): void {
event.preventDefault();
event.stopPropagation();
this.locked.set(locked);
this.position.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.trigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => this.trigger().openMenu());
}
}
@@ -3,7 +3,7 @@
[view]="activeView()"
[showEpg]="shouldShowEpg()"
/>
} @else if (displayedChannels().length) {
} @else if (showChannelViews()) {
@if (
!compact() &&
viewTitle() &&
@@ -62,11 +62,17 @@
[activeChannelUrl]="activeChannelUrl()"
[favoriteIds]="favoriteIds()"
[hiddenGroupTitles]="hiddenGroupTitles()"
[playlistId]="activePlaylist()?._id ?? null"
[lockedGroupTitles]="lockedGroupTitles()"
[withheldGroupCount]="withheldGroupCount()"
[managementGroups]="managementGroups()"
[sidebarWidth]="sidebarWidth()"
(channelSelected)="onChannelSelected($event)"
(channelPlaybackRequested)="onChannelPlaybackRequested($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(hiddenGroupTitlesChanged)="onHiddenGroupTitlesChanged($event)"
(lockedGroupTitlesChanged)="onLockedGroupTitlesChanged($event)"
(groupLockToggled)="onGroupLockToggled($event)"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
@@ -33,7 +33,11 @@
height: 64px;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0)
),
rgba(127, 127, 127, 0.1);
display: flex;
align-items: center;
@@ -6,6 +6,8 @@ import {
NavigationEnd,
Router,
} from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs';
@@ -13,6 +15,7 @@ import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import {
ParentalLockService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
@@ -126,6 +129,11 @@ describe('ChannelListContainerComponent', () => {
await TestBed.configureTestingModule({
imports: [ChannelListContainerComponent],
providers: [
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
{
provide: EpgService,
useValue: epgService,
@@ -194,6 +202,69 @@ describe('ChannelListContainerComponent', () => {
fixture = TestBed.createComponent(ChannelListContainerComponent);
});
it('keeps the groups view (and its unlock row) when every group is withheld', () => {
const withheld = signal(0);
Object.defineProperty(fixture.componentInstance, 'withheldGroupCount', {
value: withheld,
});
fixture.componentRef.setInput('activeView', 'groups');
fixture.detectChanges();
const component = fixture.componentInstance;
// No channels and nothing withheld: the generic empty state.
expect(component.showChannelViews()).toBe(false);
withheld.set(3);
expect(component.showChannelViews()).toBe(true);
// Other views have no unlock row to show.
fixture.componentRef.setInput('activeView', 'all');
expect(component.showChannelViews()).toBe(false);
});
it('drops a group lock toggle whose playlist changed while the PIN was asked', async () => {
const parentalLock = TestBed.inject(ParentalLockService);
const setM3uLocks = jest
.spyOn(parentalLock, 'setM3uLocks')
.mockResolvedValue(true);
jest.spyOn(parentalLock, 'requestUnlock').mockImplementation(
async () => {
activePlaylistSignal.set({
_id: 'playlist-2',
title: 'Playlist Two',
count: 0,
importDate: '2026-04-11T00:00:00.000Z',
} as PlaylistMeta);
return true;
}
);
await fixture.componentInstance.onGroupLockToggled({
groupKey: 'News',
locked: false,
});
expect(setM3uLocks).not.toHaveBeenCalled();
});
it('saves a group lock toggle under the playlist it was requested for', async () => {
const parentalLock = TestBed.inject(ParentalLockService);
const setM3uLocks = jest
.spyOn(parentalLock, 'setM3uLocks')
.mockResolvedValue(true);
jest.spyOn(parentalLock, 'requestUnlock').mockResolvedValue(true);
await fixture.componentInstance.onGroupLockToggled({
groupKey: 'News',
locked: true,
});
expect(setM3uLocks).toHaveBeenCalledWith(
'playlist-1',
expect.any(Function)
);
});
it('does not clear the shared channel list on destroy', () => {
fixture.detectChanges();
@@ -297,7 +368,9 @@ describe('ChannelListContainerComponent', () => {
});
epgService.getCurrentProgramsForChannels
.mockReturnValueOnce(first.asObservable())
.mockReturnValueOnce(of(new Map([['guide-news', program('Fresh')]])));
.mockReturnValueOnce(
of(new Map([['guide-news', program('Fresh')]]))
);
fixture.detectChanges();
const channels = [
@@ -13,13 +13,14 @@ import {
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
@@ -45,11 +46,13 @@ import {
Subscription,
} from 'rxjs';
import {
ParentalLockService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
ALL_CATEGORIES_WITHHELD,
foldSearchText,
Channel,
EpgProgram,
@@ -64,6 +67,7 @@ import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
import { GroupsViewComponent } from './groups-view/groups-view.component';
import type { GroupManagementDialogGroup } from './groups-view/group-management-dialog/group-management-dialog.component';
import {
RecentViewComponent,
RecentViewItem,
@@ -116,6 +120,9 @@ const EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS = 2000;
export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly epgService = inject(EpgService);
private readonly playlistsService = inject(PlaylistsService);
private readonly parentalLock = inject(ParentalLockService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly router = inject(Router);
@@ -126,7 +133,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Route-aware playlist ID for recent-item mutations */
private readonly resolvedPlaylistId =
this.playlistContext.resolvedPlaylistId;
private readonly activePlaylist = this.playlistContext.activePlaylist;
protected readonly activePlaylist = this.playlistContext.activePlaylist;
/** Map of channel ID to current EPG program */
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
@@ -316,9 +323,88 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
return playlist.hiddenGroupTitles ?? [];
});
/** Displayed channels - filters out unfavorited channels in global favorites view */
/** The M3U playlist the parental lock keys its group locks by. */
private readonly lockPlaylistId = computed(() => {
const playlist = this.activePlaylist();
if (!playlist || playlist.serverUrl || playlist.macAddress) {
return null;
}
return playlist._id;
});
/** Locked group titles for the management dialog; `null` while the lock is off. */
readonly lockedGroupTitles = computed<string[] | null>(() => {
this.parentalLock.version();
const playlistId = this.lockPlaylistId();
if (!playlistId || !this.parentalLock.enabled()) {
return null;
}
return this.parentalLock.lockedGroupTitles(playlistId);
});
/**
* Groups of this playlist the lock currently withholds; the groups rail
* shows one "N locked · Enter PIN to show" row for them, as the portal
* category rail does.
*/
readonly withheldGroupCount = computed(() => {
const withheld = this.withheldGroupTitles();
if (withheld.size === 0) {
return 0;
}
return this.managementGroups().filter((group) =>
withheld.has(group.key)
).length;
});
/**
* Whether the channel views render instead of the generic empty state.
* With every M3U group locked the filtered list is empty, yet the groups
* view must still render: it carries the "N locked · Enter PIN to show"
* row, the only in-context way back.
*/
readonly showChannelViews = computed(
() =>
this.displayedChannels().length > 0 ||
(this.activeView() === 'groups' && this.withheldGroupCount() > 0)
);
/** Group titles currently withheld: locked AND the lock is active. */
private readonly withheldGroupTitles = computed(() => {
this.parentalLock.version();
const playlistId = this.lockPlaylistId();
if (!playlistId || !this.parentalLock.active()) {
return new Set<string>();
}
if (this.parentalLock.withholdsEverything?.()) {
return ALL_CATEGORIES_WITHHELD;
}
return new Set(this.parentalLock.lockedGroupTitles(playlistId));
});
/**
* Every channel of the playlist minus the withheld groups. All views,
* the fullscreen panel and numeric zapping derive from this list, so a
* locked group cannot resurface through any of them.
*/
private readonly visibleChannelList = computed(() => {
const withheld = this.withheldGroupTitles();
const channels = this.channelListSignal();
if (withheld.size === 0) {
return channels;
}
return channels.filter(
(channel) => !withheld.has(channel.group?.title ?? '')
);
});
private readonly visibleChannelList$ = toObservable(
this.visibleChannelList
);
/** Every group with its size, locked ones included, for the management dialog. */
readonly managementGroups = computed<GroupManagementDialogGroup[]>(() =>
Object.entries(groupChannelsByTitle(this.channelListSignal())).map(
([key, channels]) => ({ key, count: channels.length })
)
);
/** Displayed channels - the playlist minus the parental lock's withheld groups */
readonly displayedChannels = computed(() => {
return this.channelListSignal();
return this.visibleChannelList();
});
/** Object with channels sorted by groups */
@@ -327,7 +413,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
);
readonly recentChannelItems = computed<RecentViewItem[]>(() => {
const channels = this.channelListSignal();
const channels = this.visibleChannelList();
const recentItems = this.recentItems();
const channelsByUrl = new Map(
channels.map((channel) => [channel.url, channel] as const)
@@ -382,7 +468,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** List with favorites */
favorites$ = combineLatest([
this.store.select(selectFavorites),
this.channelList$,
this.visibleChannelList$,
]).pipe(
map(([favoriteChannelIds, channelList]) => {
const channelsByUrl = mapChannelsByFirstUrl(channelList);
@@ -531,6 +617,62 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
);
}
async onLockedGroupTitlesChanged(
lockedGroupTitles: string[]
): Promise<void> {
await this.saveGroupLocks(lockedGroupTitles);
}
/**
* One group from the right-click menu, behind the PIN, applied inside
* the store's queue. The playlist is captured before the prompt and must
* still be the open one afterwards: two playlists can share a group
* name, and the edit must not land on the other one.
*/
async onGroupLockToggled(change: {
groupKey: string;
locked: boolean;
}): Promise<void> {
const playlistId = this.lockPlaylistId();
if (
!playlistId ||
!(await this.parentalLock.requestUnlock()) ||
this.lockPlaylistId() !== playlistId
) {
return;
}
await this.saveGroupLocks(
(current) =>
change.locked
? [...new Set([...current, change.groupKey])]
: current.filter((title) => title !== change.groupKey),
playlistId
);
}
private async saveGroupLocks(
groupTitles: string[] | ((current: readonly string[]) => string[]),
playlistId = this.lockPlaylistId()
): Promise<void> {
if (!playlistId) {
return;
}
// The dialog has closed by now, so a failed write (storage full or
// unavailable, app-state IPC failure) must be reported here, or the
// user relocks believing the chosen groups are protected.
const saved = await this.parentalLock.setM3uLocks(
playlistId,
groupTitles
);
if (!saved) {
this.snackBar.open(
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED'),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
}
onHiddenGroupTitlesChanged(hiddenGroupTitles: string[]): void {
const playlist = this.activePlaylist();
@@ -5,6 +5,12 @@
<div class="selection-info">
{{ 'CHANNELS.GROUP_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
@if (showLocks) {
<span class="selection-info__locks">
· {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
}
</div>
<div class="bulk-actions">
<button
@@ -66,6 +72,33 @@
}
</span>
<span class="item-count">({{ group.count }})</span>
@if (showLocks) {
<button
type="button"
mat-icon-button
class="lock-toggle"
[class.lock-toggle--locked]="group.locked"
[attr.aria-pressed]="group.locked"
[attr.data-test-id]="'group-lock-' + group.key"
[matTooltip]="
(group.locked
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
[attr.aria-label]="
(group.locked
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
(click)="toggleLock(group, $event)"
>
<mat-icon>{{
group.locked ? 'lock' : 'lock_open'
}}</mat-icon>
</button>
}
</div>
} @empty {
<div class="empty-message">
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more