mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/channel-history-playback-check-0b3bd9
# Conflicts: # libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts
This commit is contained in:
commit
02ed4e589c
378 files changed
+16812
-1098
No files matched your search
@@ -149,6 +149,18 @@ Otherwise the app may still be using an older
|
||||
`dist/apps/electron-backend/workers/database.worker.js` bundle even though the
|
||||
TypeScript source has already been updated.
|
||||
|
||||
### Parental Lock Toggles
|
||||
|
||||
While the parental lock is enabled (`Settings → Parental lock`), the same
|
||||
dialog renders a lock toggle per row and the whole dialog opens only after the
|
||||
PIN. Locks are a separate concept from `hidden`: they live in the renderer's
|
||||
lock store keyed by provider category id and are mirrored into
|
||||
`categories.locked` through `DB_SET_CATEGORY_LOCKS`, so a refresh keeps them
|
||||
the same way it keeps `hidden`. The dialog is the one entry point on every
|
||||
portal type (Stalker's "Manage categories" button opens its lock-only
|
||||
dialog); a right-click on a category row offers a single-row Lock/Unlock
|
||||
through the same lock store. Contract: [parental lock](parental-lock.md).
|
||||
|
||||
## Files Changed
|
||||
|
||||
```
|
||||
|
||||
@@ -243,23 +243,47 @@ and value-imports those two dependency-free modules directly.
|
||||
`@iptvnator/workspace/shell/util/settings-search` exists for the opposite
|
||||
reason: the main `workspace-shell-util` barrel is imported eagerly, and the
|
||||
settings search index must stay in the lazy settings and shell chunks.
|
||||
`@iptvnator/workspace/shell/util/settings-context` keeps
|
||||
`SettingsContextService`, which only the lazy settings page and settings
|
||||
context panel use, out of that barrel for the same reason, and
|
||||
`@iptvnator/services/playlist-backup` keeps `PlaylistBackupService` (about
|
||||
22 KB, used only by the lazy settings page) out of the `@iptvnator/services`
|
||||
barrel.
|
||||
|
||||
The web app's root shell (`app.component.ts`, `app.config.ts` and the services
|
||||
they construct) is on the renderer's initial path, where a barrel costs its
|
||||
whole library: esbuild keeps every Angular component module a barrel
|
||||
re-exports, because their static definitions count as side effects. Importing
|
||||
re-exports, because their static definitions count as side effects (so do an
|
||||
`@Injectable` service's and a pipe's). Importing
|
||||
`WindowControlsComponent` and `DialogService` from `@iptvnator/ui/components`
|
||||
once put the channel lists, EPG views, `@angular/forms`, `date-fns` and the
|
||||
Stalker data layer into `main.js`. The root shell therefore uses file-level
|
||||
entries: `@iptvnator/ui/components/window-controls`,
|
||||
`@iptvnator/ui/components/confirm-dialog` and
|
||||
`@iptvnator/playlist/shared/ui/stalker-connection-editor` (like the existing
|
||||
`@iptvnator/ui/epg/progress-panel`), and loads anything used only on demand
|
||||
`@iptvnator/ui/components/confirm-dialog`,
|
||||
`@iptvnator/playlist/shared/ui/stalker-connection-editor`,
|
||||
`@iptvnator/playlist/shared/util/playlist-file-import` (the barrel would add
|
||||
`PlaylistContextFacade`) and `@iptvnator/pipes/date-format` (the barrel would
|
||||
add `SafePipe`; the eager EPG progress panel uses it too), like the existing
|
||||
`@iptvnator/ui/epg/progress-panel`, and loads anything used only on demand
|
||||
through a local file it imports dynamically (the Stalker connection editor,
|
||||
the release-notes dialog, the external-player info dialog). A local file,
|
||||
the release-notes dialog, the external-player info dialog, the parental-lock
|
||||
PIN dialog and the Stalker step of the parental-lock enforcement, which runs
|
||||
only while a Stalker route is open). A local file,
|
||||
not the library alias, is the dynamic-import target because
|
||||
`@nx/enforce-module-boundaries` forbids static imports of a library the same
|
||||
project also loads dynamically. `renderer.initialBytes` in
|
||||
project also loads dynamically.
|
||||
|
||||
A barrel the initial path imports also costs code it never runs itself:
|
||||
code splitting places a module in the chunk shared by every entry point
|
||||
that reaches it, so a helper that only lazy routes use, but that an eager
|
||||
file can reach through a barrel's re-export, lands in an initial chunk even
|
||||
though tree shaking would drop it from a single bundle. The eager Xtream
|
||||
data layer and root shell therefore import `@iptvnator/portal/shared/util/logger`
|
||||
and `@iptvnator/portal/shared/util/tokens` (the portal DI tokens), not the
|
||||
`@iptvnator/portal/shared/util` barrel, whose navigation, keyboard-shortcut
|
||||
and download helpers (about 45 KB) belong to the lazy portal routes. A
|
||||
type-only import of a barrel uses `import type`, so it can never keep the
|
||||
barrel reachable. `renderer.initialBytes` in
|
||||
[performance journeys](performance-journeys.md) guards the result; to see why a
|
||||
module is eager, build with `pnpm nx build web --stats-json` and follow the
|
||||
static imports in `dist/apps/web/stats.json` from `apps/web/src/main.ts`.
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
# Parental Lock
|
||||
|
||||
Issue #285. A PIN-protected lock for individual categories: Xtream
|
||||
categories, Stalker genres and M3U `group-title`s. While the lock is active,
|
||||
everything in a locked category is withheld from the app; entering the PIN
|
||||
shows it again until the app locks itself.
|
||||
|
||||
This document is the contract. Phase 1 (this document's scope) covers the
|
||||
portals' own surfaces; the aggregate surfaces listed under "Not yet covered"
|
||||
follow in a second PR.
|
||||
|
||||
## Product rules
|
||||
|
||||
- Off by default. Enabling asks for a new PIN (4–8 digits); disabling and
|
||||
changing the PIN always verify the current PIN against the stored hash,
|
||||
even while the session is unlocked (`verifyCurrentPin()`, never the
|
||||
`requestUnlock()` short-cut) — a parent leaving the app unlocked must not
|
||||
leave the lock removable. Disabling keeps the locks for a later re-enable.
|
||||
- The unit of locking is the category. Nothing is blurred or greyed: a
|
||||
withheld category and its rows are absent. The only trace is one
|
||||
"N locked · Enter PIN to show" row at the bottom of a portal's category
|
||||
rail, which opens the PIN prompt. In fail-closed mode (lock store
|
||||
unreadable) the row shows without a count, since which categories are
|
||||
locked is unknown while every one of them is withheld.
|
||||
- The unlock lives in memory only. The app locks again on every restart, on
|
||||
"Lock now" (header button, command palette, settings), and after
|
||||
`Settings.parentalLockRelockMinutes` minutes without user interaction
|
||||
(default 15; `0` = only on restart). The idle timer follows the UNLOCKED
|
||||
transition: armed the moment a session is unlocked — including the session
|
||||
that just enabled the feature — and disarmed on lock. Active playback of a built-in web
|
||||
player counts as interaction, so a film never locks half way — video
|
||||
through the keep-awake tracker, and audio (the radio player) read from
|
||||
the playing `<audio>` elements, since the keep-awake service ignores
|
||||
them.
|
||||
- This is a child lock, not a security boundary: the PIN hash and the lock
|
||||
store sit in user-readable app data. The UI says so. There is no PIN
|
||||
recovery; resetting the app data is the way out.
|
||||
|
||||
## Storage
|
||||
|
||||
| What | Where (Electron) | Where (PWA) |
|
||||
| ------------------ | --------------------------------------------------------------------------------- | ------------------------- |
|
||||
| PIN hash | `app_state` key `parental-lock:pin` | localStorage, same key |
|
||||
| Lock store | `app_state` key `parental-lock:locks` | localStorage, same key |
|
||||
| Feature switch | `Settings.parentalLockEnabled`, mirrored to electron-conf `PARENTAL_LOCK_ENABLED` | `Settings` |
|
||||
| Relock timeout | `Settings.parentalLockRelockMinutes` | same |
|
||||
| Xtream query index | `categories.locked` column | none (filtered in memory) |
|
||||
|
||||
The PIN is hashed with PBKDF2-SHA256 through WebCrypto
|
||||
(`parental-lock-pin.util.ts`, format `v1$<iterations>$<salt>$<hash>`), so
|
||||
Electron and PWA share one implementation. The hash is deliberately not part
|
||||
of `Settings`: settings are logged, backed up and mirrored to the main
|
||||
process. A hash that could not be READ is not an absent PIN:
|
||||
`ParentalLockStorageService.readPinHash()` reports the failure as `null`
|
||||
(an absent PIN is `{hash: null}`), the session then stays locked (`enabled`
|
||||
treats an unreadable PIN as set while the switch is unknown) and every
|
||||
PIN-protected step — unlock, change PIN, disable — re-reads it first, so
|
||||
storage answering later is enough to recover without a restart.
|
||||
|
||||
The lock store (`ParentalLockStore` in `parental-lock.util.ts`) is keyed by
|
||||
playlist id and holds, per playlist, Xtream `{categoryType, xtreamId}`
|
||||
pairs, Stalker `{categoryType, categoryId}` pairs and M3U group titles. It
|
||||
is the source of truth for all three portal types. Xtream additionally keeps
|
||||
`categories.locked` as a SQLite index derived from it: `DB_SET_CATEGORY_LOCKS`
|
||||
re-stamps one playlist/type from a provider-id list (idempotent, so the store
|
||||
can be replayed after a refresh or a backup restore), and `DB_SAVE_CATEGORIES`
|
||||
takes `lockedCategoryXtreamIds` so a refresh recreates the rows already
|
||||
stamped. Locks are keyed by provider ids, never by SQLite row ids, because a
|
||||
refresh deletes and re-inserts the rows.
|
||||
|
||||
## Enforcement — two layers, both default-closed
|
||||
|
||||
1. **SQLite worker (Electron).** `apps/electron-backend/src/app/database/parental-lock-state.ts`
|
||||
holds one process-wide flag. While active, every content-returning read
|
||||
appends "category is not locked": `getCategories`, `getContent`,
|
||||
`searchContent`, the three `globalSearch` candidate selectors,
|
||||
`getGlobalRecentlyAddedByType`, `DB_MATCH_TITLES` and the multi-source
|
||||
discovery queries. A surface added later is therefore withheld by
|
||||
default. The flag is seeded through `workerData.parentalLockActive` when
|
||||
the worker is (re)started and flipped by the `parental-lock` control
|
||||
message on the request port (ordered with the requests, so a read posted
|
||||
after it observes the new state). `getAllCategories` is exempt: it feeds
|
||||
the management dialog, which itself sits behind the PIN.
|
||||
2. **Renderer `ParentalLockService`** (`libs/services/src/lib/parental-lock/`).
|
||||
Signals `enabled`, `unlocked`, `active` (= enabled && !unlocked),
|
||||
`hasPin`, `version`; `requestUnlock()` (one shared prompt for concurrent
|
||||
callers), `lock()`, `setupPin()`, `changePin()`, `disable()`, the lock
|
||||
store API (`setXtreamLocks`, `setStalkerLocks`, `setM3uLocks`,
|
||||
`replacePlaylistLocks`, `lockedXtreamIds`, …) and the `is*Locked`
|
||||
predicates, which answer `true` only while `active`. The PIN dialog is
|
||||
reached through the `PARENTAL_LOCK_PROMPT` token, provided by the app
|
||||
(`AppParentalLockPromptService` → `ParentalLockPinDialogComponent` in
|
||||
`libs/ui/components`), so the data-access lib stays free of UI.
|
||||
Five wrong PINs pause the prompt for 30 seconds. The count and the
|
||||
pause live in the service (`createParentalLockPinThrottle`, handed to
|
||||
every unlock prompt), so dismissing the dialog and opening it again
|
||||
does not reset them.
|
||||
|
||||
The renderer reports `active` to the main process over
|
||||
`PARENTAL_LOCK_SET_STATE` (`apps/electron-backend/src/app/events/parental-lock.events.ts`),
|
||||
which forwards it to the worker. `SETTINGS_UPDATE` only persists the
|
||||
`parentalLockEnabled` mirror; it never derives the live state from a settings
|
||||
save (every save carries the flag unchanged and would re-lock the worker
|
||||
under a renderer that shows "unlocked"). The one exception is a switch-off,
|
||||
which releases the worker at once. `requestUnlock()` awaits `initialize()`
|
||||
before it can answer "not active", so a slow settings load cannot open a
|
||||
gate. Like the playback keep-awake vote, the
|
||||
renderer's word does not outlive its page: on reload, main-frame navigation or
|
||||
a dead render process the flag falls back to the mirrored
|
||||
`PARENTAL_LOCK_ENABLED` setting, i.e. locked while the feature is on. The
|
||||
renderer, in turn, reports nothing before its settings have loaded, so it can
|
||||
never send a spurious "unlocked" while the main process still holds the
|
||||
locked default. Unreadable settings fail the same way: `SettingsStore`
|
||||
serves defaults and records `storageFailure = 'load'`, and the switch is
|
||||
then unknown rather than off — a stored PIN stands in for it (`enabled`
|
||||
follows `hasPin`, so the session starts locked and one PIN entry unlocks
|
||||
it), and without a PIN the renderer does not report at all, leaving the
|
||||
main process on its mirrored default instead of announcing "unlocked" on
|
||||
the strength of default settings. The lock store itself fails closed the
|
||||
same way: `ParentalLockStorageService.readLocks()` reports a failed read as
|
||||
`null` (distinct from an absent store, `{}`; Electron reads through
|
||||
`DatabaseService.readAppState`, which keeps a rejected IPC apart from a
|
||||
missing key, and a stored payload that does not parse or does not have the
|
||||
shape `writeLocks` produces — `isWellFormedParentalLockStore`, all three
|
||||
lists present, down to the nested entries — is a failed read too — corruption never becomes an empty
|
||||
store), and while the lock is active with the store unreadable
|
||||
`ParentalLockService.withholdsEverything` is true — every `is*Locked`
|
||||
predicate answers true and the set-based filters (PWA Xtream, Stalker
|
||||
content and search, the M3U channel list) receive
|
||||
`ALL_CATEGORIES_WITHHELD`, and `ElectronXtreamDataSource` serves no
|
||||
categories, content, cached categories/content (the warm-route hydration
|
||||
path) or search hits either, since its SQLite index may still
|
||||
carry a stale stamp — under which a row WITHOUT a genre is withheld
|
||||
too (`isStalkerItemWithheld`, `isStalkerCategoryLocked`), since "no genre"
|
||||
must not be the one row a withheld catalog still shows — so the whole
|
||||
catalog is withheld until the PIN
|
||||
is entered or the store reads again (`requestUnlock` and every lock write
|
||||
retry the read first, and a write is refused while it still fails, since it
|
||||
would be built on an empty in-memory store and wipe the persisted locks).
|
||||
The same fail-closed mode applies while locked when Electron reads Xtream
|
||||
through the SQLite worker (`supportsXtreamSqliteDataSource`) but the bridge
|
||||
lacks the worker filter (`supportsParentalLockSqliteFilter`:
|
||||
`setParentalLockState` and `dbSetCategoryLocks`, e.g. a partial or older
|
||||
preload): the worker would never learn the lock state, so it cannot
|
||||
withhold locked rows itself. The same holds while locked after the
|
||||
lock-state sync itself was rejected (`ParentalLockWorkerSync`), until a
|
||||
later sync succeeds. The direct worker consumers
|
||||
(`CatalogTitleMatchService`, `VodSourceDiscoveryService`) ask the worker
|
||||
nothing while `withholdsEverything` is true, in either case. The VOD
|
||||
multi-source host keys its discovery session to the lock version: a lock
|
||||
change drops the discovered sources, retires discoveries and switches in
|
||||
flight, and rediscovers through the worker's new lock state. Title-match
|
||||
results cached by the Actor/Discover routes and dashboard services are not
|
||||
yet retired on relock (tracked in #1723).
|
||||
The window before the initial read settles is treated the same way
|
||||
(`ParentalLockLockStore.readable` is false until then): settings can report
|
||||
the feature as on before the locks are known — and the workspace route's
|
||||
`settingsReady` resolver awaits `ParentalLockService.initialize()` next to
|
||||
the settings load, so no route or catalog activates before the PIN and the
|
||||
lock store are known (in the PWA the settings read can be the slower one).
|
||||
Every lock write re-reads a failed store BEFORE building its edit, so a
|
||||
recovered store is edited, never overwritten by an edit built on the empty
|
||||
fail-closed one. The feature switch itself is
|
||||
persisted through one guarded path (`persistEnabled`), and every
|
||||
parental-lock settings write first retries a failed startup settings read
|
||||
(`ensureSettingsReadable`) and is refused while settings stay unreadable —
|
||||
`updateSettings` writes the whole settings object, which after a failed
|
||||
read is the defaults and would replace the user's persisted preferences.
|
||||
Within that path `updateSettings`
|
||||
patches memory before it writes, so a failed write is undone in memory and
|
||||
`setupPin`/`disable` report false (whether to persist is decided from the
|
||||
settings switch BEFORE the PIN is stored, since `enabled` follows `hasPin`
|
||||
while the switch is unknown); the Electron mirror write is awaited
|
||||
next, and a mirror that cannot be written undoes the settings write the
|
||||
same way — the toggle never shows a state the next launch will not have,
|
||||
on either side. The undo restores the value read AFTER the settings retry
|
||||
(not the hard-coded inverse, which a recovered read may already hold). The
|
||||
Settings switch itself only requests the change: it snaps back to the
|
||||
saved state at once and follows `enabled()` when the PIN action succeeds,
|
||||
so a cancelled or refused PIN leaves it showing the real state.
|
||||
|
||||
### In-memory catalogs
|
||||
|
||||
- **Xtream (Electron):** the store reads categories and streams through the
|
||||
worker, so they arrive filtered. On `version` changes
|
||||
`ParentalLockEnforcementService` (`apps/web/src/app/services/`) calls
|
||||
`XtreamStore.reloadCategories()` + `reloadCachedContent()` and, if the
|
||||
selected category vanished, clears the selection and navigates to the
|
||||
section root. The selected ITEM is judged separately, on its own
|
||||
`category_id` against the reloaded category list: a detail opened from
|
||||
"All", recently added or search has no selected category to vanish
|
||||
with, so its row disappearing from a list is not enough — the item is
|
||||
cleared and the route returns to the section root while the (still
|
||||
readable) selected category stays. Within one apply the synchronous
|
||||
surfaces (the M3U active channel, the Stalker selection) are stepped off
|
||||
BEFORE the Xtream reloads are awaited, so a playing M3U channel never
|
||||
waits behind a slow portal read — the Xtream store stays populated after
|
||||
leaving that portal, so its reload runs on every apply. The M3U check
|
||||
also runs whenever the active channel changes while locked: numeric
|
||||
zapping, next/previous and remote commands select from the full channel
|
||||
list without any lock change following, so a channel of a locked group
|
||||
is reset as soon as it becomes active (numeric zapping also skips it). Applies run one
|
||||
at a time and each is
|
||||
abandoned once a newer `version` exists (the queued apply reads the latest
|
||||
state): `ElectronXtreamDataSource` shares in-flight category/content
|
||||
reads per playlist and type, and its share key carries the lock version,
|
||||
so an unlock refresh still running when "Lock now" arrives can never hand
|
||||
the relock its unfiltered rows. The apply also re-runs the stored
|
||||
in-portal search (`XtreamStore.refreshSearchResults`, the last
|
||||
`searchContent` call as issued) — `searchResults` is a separate array the
|
||||
search page renders directly and would otherwise keep locked titles until
|
||||
the query changes. A RELOCK fails closed at once rather than after the
|
||||
database answers — ahead of the serialized apply queue, so an earlier
|
||||
apply still waiting on a slow or hung read cannot delay it: the selected detail is stepped off synchronously when
|
||||
the lock store already names its category (the pre-reload category list
|
||||
maps Electron's row id to the provider id) or when that list cannot place
|
||||
it at all (a manually hidden category opened through search), then
|
||||
`withholdCatalog()`
|
||||
empties every catalog list and `clearSearchResults()` the stored search
|
||||
(retiring a search still in flight, which was issued under the previous
|
||||
lock state) before the filtered reads refill them; both reloads take a publish guard
|
||||
answered before every state patch, so a read issued under an older lock
|
||||
version is dropped instead of published. The guard also turns false once
|
||||
another Xtream playlist is open (the store is a singleton), and the store
|
||||
itself refuses to publish a reload into a playlist it was not read for;
|
||||
the post-reload search refresh and selection checks are skipped then
|
||||
too. The post-reload checks of the
|
||||
selected category and item decide by the LOCK STORE through the
|
||||
unfiltered category rows (`IXtreamDataSource.getAllCategories`), not by
|
||||
absence from the reloaded list, which also omits categories the user
|
||||
merely hid; rows that cannot be read fail closed. A lock change that overtakes
|
||||
the INITIAL hydration (the content is not initialized yet, so the reload
|
||||
has nothing to re-read) sets a deferred-reload flag instead — already
|
||||
when `withholdCatalog()` empties the lists, before the category reload is
|
||||
awaited: the
|
||||
hydration then publishes empty lists in place of the rows it read under
|
||||
the previous lock state (categories included), and the filtered reload
|
||||
of categories and content runs as soon as the hydration settles, on
|
||||
every path that marks the content initialized, under the publish guard
|
||||
of the request that deferred it.
|
||||
Both reloads fail closed: a category reload that
|
||||
rejects empties the three category lists, and a per-type content reload
|
||||
that rejects empties that type and sets it back to `idle` so the next
|
||||
visit loads it again (filtered) — rows read under the previous lock state
|
||||
are never kept. Live playback is stopped by the layout itself:
|
||||
`LiveStreamLayoutComponent` keeps the playing channel's provider category
|
||||
id (mapped from the SQLite row id on Electron — through the visible
|
||||
category list, else through the unfiltered rows, since search can play a
|
||||
channel of a HIDDEN category; until that lookup lands the id is unknown
|
||||
and a relock stops the channel, and a lookup that lands after a later
|
||||
playback — same provider id, other playlist — is discarded) and drops `activePlayback` on a `version`
|
||||
change that locks it, because the player is gated on that
|
||||
signal, not on the store selection — which an ordinary category switch
|
||||
also clears while the channel keeps playing.
|
||||
- **Stalker:** the same service checks the selected genre through
|
||||
`isStalkerCategoryLocked` and, independently, the selected item's own
|
||||
genre — `tv_genre_id` for live and radio rows, `category_id` for VOD and
|
||||
series, the rule the store's withheld filter applies — so an item opened
|
||||
from `*` (All) or search is withheld by its genre even though `*` itself
|
||||
can never be locked. A withheld item is cleared and the section root is
|
||||
navigated to; the category selection is reset only when the genre itself
|
||||
is locked. A stream still RESOLVING at relock time is retired too: the
|
||||
embedded player defers selecting the channel until resolution, so the
|
||||
cleared selection cannot retire it — the playback request carries the
|
||||
lock version it was issued under (`isStalkerPlaybackRequestLockCurrent`)
|
||||
and is dropped when the session relocked meanwhile (an unlock lets it
|
||||
complete). `StalkerLiveStreamLayoutComponent` drops its `activePlayback`
|
||||
whenever the store selection is cleared: its template mounts the player
|
||||
only with a selection, and the held stream must not resurface with the
|
||||
next one.
|
||||
- **M3U groups rail:** `ChannelListContainerComponent` derives
|
||||
`withheldGroupCount` (groups of the playlist the active lock withholds)
|
||||
and the groups rail renders the same "N locked · Enter PIN to show" row
|
||||
as the portal category rail, so locked groups do not simply vanish —
|
||||
also when EVERY group is locked: the container keeps the groups view
|
||||
(`showChannelViews`) and the groups view keeps its rail instead of the
|
||||
generic empty state.
|
||||
- **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories,
|
||||
streams and search hits at read time; the same reloads apply. Provider
|
||||
category ids are compared in canonical numeric form (`"009"` matches the
|
||||
stored lock `9`), as the lock editor stores them with `Number`.
|
||||
- **Warm-cache detection (Electron):** the filtered category/content reads
|
||||
can be empty while the offline cache is complete (every category locked),
|
||||
so `ElectronXtreamDataSource` confirms an empty read with the unfiltered
|
||||
`hasXtreamCategories` / `hasXtreamContent` before refetching from the
|
||||
provider.
|
||||
- **Routes:** `parentalLockXtreamCategoryGuard(section)` on every Xtream
|
||||
`:categoryId` route (live, vod, series and their detail children) and
|
||||
`parentalLockStalkerCategoryGuard(section)` on the Stalker vod/series
|
||||
`:categoryId` routes prompt for the PIN when a locked category is reached
|
||||
by URL — bookmark, typed address, stale link — and redirect to the section
|
||||
root on refusal. Detail routes also resolve the ITEM's own category through
|
||||
`getContentByXtreamId`, so a locked movie paired with an unlocked category
|
||||
id in the URL is still refused; on a cold PWA navigation the guard
|
||||
hydrates the session cache first (`getPlaylist` + `getContent`), and an
|
||||
item the catalog cannot place fails closed (PIN prompt) rather than
|
||||
passing as unlocked. Electron routes carry SQLite row ids, so
|
||||
the Xtream guard maps them through the unfiltered category read; Stalker
|
||||
routes already carry the genre id.
|
||||
- **Stalker withheld-row bookkeeping:** the paging logic that advances past
|
||||
a page made only of withheld rows keys those rows by
|
||||
`stalkerWithheldRowKey` (`id`, else `stream_id`/`movie_id`/`series_id`,
|
||||
else the row's `cmd`/`name`), never by a shared `''`, so a page of new
|
||||
locked rows is not mistaken for a stalled portal.
|
||||
- **Stalker search relock:** a lock change drops the withheld rows already
|
||||
on screen at once (`applyRelockToResults`, page 1 included) and closes an
|
||||
open detail of a now-withheld genre (in fail-closed mode also one
|
||||
without a genre), before the replacement page is awaited — otherwise
|
||||
they stay clickable while that request is pending.
|
||||
- **Stalker search staleness:** portal requests are not aborted, so a
|
||||
search page issued before a relock can finish after it, filtered with the
|
||||
pre-relock withheld set; `isStalkerSearchRequestCurrent` keys the
|
||||
response on the parental lock version as well as term/type/page/portal,
|
||||
so such a page is dropped instead of repopulating the grid.
|
||||
- **Stalker search route** (`/workspace/stalker/:id/search`, no category
|
||||
in the URL): `StalkerSearchComponent` filters each portal page through the
|
||||
same withheld-genre predicate, carries `parentalLockVersion` in its
|
||||
resource params, judges paging progress on the raw page (a page made only
|
||||
of locked rows is not the end of the results), advances past a run of
|
||||
fully withheld pages by itself (the infinite scroll stops auto-filling
|
||||
after a few no-growth loads), closes an open detail whose genre is newly
|
||||
withheld and, on a lock flip past page 1, drops the withheld rows and
|
||||
restarts from page 1.
|
||||
- **Stalker:** genres are stored unfiltered; `getCategoryResource` filters
|
||||
them, `getAllCategoriesForSelectedType` is the raw list for the lock
|
||||
dialog. `itvFullChannelList` and the content loader drop rows whose
|
||||
`tv_genre_id` / `category_id` is withheld, the content resource's params
|
||||
carry `parentalLockVersion` so lock/unlock re-fires it, and a stale
|
||||
response is discarded when the version moved. Paging is judged on the RAW
|
||||
page: withheld ids the list has not seen before count as progress (so a
|
||||
portal page made only of locked rows does not end the list), a page that
|
||||
is entirely withheld requests the next page by itself, and the VOD/series
|
||||
`totalCount` is reduced by the withheld ids seen so the grid stops asking
|
||||
once every visible row is in. A lock flip while the list sits past page 1
|
||||
drops the withheld rows on screen at once and restarts from page 1, so rows
|
||||
accumulated under the old lock state are never appended to. A selected
|
||||
withheld genre is cleared and the
|
||||
section root is navigated to.
|
||||
- **M3U:** `ChannelListContainerComponent` derives one `visibleChannelList`
|
||||
(all views, favorites, recents, the fullscreen panel and numeric zapping
|
||||
read from it); locked groups are absent from the groups rail. A playing
|
||||
channel of a locked group is reset on lock.
|
||||
|
||||
## UI
|
||||
|
||||
- `Settings → Parental lock` (`/workspace/settings/parental`): enable
|
||||
toggle, state + Lock now / Unlock, relock select, Change PIN, the
|
||||
disclaimer. Everything applies immediately (it gates on the PIN), outside
|
||||
the shared dirty form; `createSettingsFromFormValue` carries both values
|
||||
from the current settings so Save cannot undo them.
|
||||
- One entry point per rail, the same on every portal type: the "Manage
|
||||
categories" button (`tune` icon; "Manage groups" on an M3U playlist).
|
||||
Xtream and M3U render lock toggles inside their existing hide/show
|
||||
dialogs while the feature is on; Stalker has no hide/show dialog, so its
|
||||
button — shown only while the feature is on — opens the lock-only
|
||||
`StalkerCategoryLockDialogComponent`, which also offers "Lock adult
|
||||
(18+)" for genres the portal flags `censored`. All three list the locked
|
||||
names and can rewrite the locks, so each opens only after
|
||||
`requestUnlock()` succeeds — and closes itself, discarding the draft, the
|
||||
moment `active` becomes true again (idle relock, Lock now), since the
|
||||
PIN gate covers only the opening. The M3U group dialog does this only
|
||||
while it carries lock toggles; the plain hide/show editor is not behind
|
||||
the PIN. The Xtream dialog loads its candidates through the
|
||||
capability-selected data source (`IXtreamDataSource.getAllCategories`,
|
||||
which the PWA source answers from its session cache or the API), so PWA
|
||||
users can set locks too; the hide/show checkboxes remain Electron-only.
|
||||
A deliberate non-choice: no dedicated lock button in the rail header
|
||||
(the header already carries search, sort and manage, and the lock is a
|
||||
category-management concern) and no keyword-based "adult" recognition
|
||||
for Xtream/M3U (provider naming is arbitrary; the only automation is the
|
||||
portal's own `censored` flag on Stalker).
|
||||
- Right-click accelerator: a `contextmenu` on a category row (Xtream and
|
||||
Stalker rail, `WorkspaceContextCategoryViewComponent`) or an M3U group
|
||||
row opens the shared `CategoryLockMenuComponent` (`libs/ui/components`)
|
||||
with one "Lock / Unlock category (group)" item, only while the feature
|
||||
is on. The portal rail persists through
|
||||
`WorkspaceCategoryLockActionService` (PIN gate, then one-id edit of the
|
||||
lock store, failure snackbar); the M3U rail emits the toggled group to
|
||||
`ChannelListContainerComponent`, which captures the playlist, asks for
|
||||
the PIN, and persists the edit only if that playlist is still open (two
|
||||
playlists can share a group name). The M3U management dialog is bound
|
||||
the same way: `GroupsViewComponent` captures its `playlistId` input
|
||||
before the PIN and drops the dialog's result once another playlist is
|
||||
open. Every bulk lock editor (Xtream and M3U management dialogs, the
|
||||
Stalker lock dialog) builds its draft only from a lock store that was
|
||||
read (`ensureLocksReadable`): a draft taken from the empty fail-closed
|
||||
snapshot would erase the real locks on Save if storage recovered in
|
||||
between. The M3U and Xtream dialogs then open without lock toggles; the
|
||||
Stalker dialog does not open. Every one of them closes on a relock, the
|
||||
M3U one also when it opened without toggles, since it still lists every
|
||||
group name. Bulk actions stay in the dialog, and while the session is locked
|
||||
a locked category is not in the rail, so unlocking always goes through
|
||||
the "N locked · Enter PIN to show" row or the dialog. The M3U dialog
|
||||
hands its lock list back to `ChannelListContainerComponent`, which
|
||||
awaits the write and reports a failed save in a snackbar (the dialog has
|
||||
closed by then). On Electron the `categories.locked` re-stamp
|
||||
(`setCategoryLocks`) clears and re-locks one playlist/type inside ONE
|
||||
transaction, so a failed restamp keeps the previous index instead of
|
||||
leaving every category unlocked. The relock timeout persists through the
|
||||
same undo-on-failure pattern as the switch (`setRelockMinutes` reverts
|
||||
the in-memory value and the facade shows the settings save-failure
|
||||
snackbar). A locked session cannot change it: the selector is disabled
|
||||
until the PIN is entered, and `setRelockMinutes` refuses while `active`,
|
||||
so a child cannot switch the idle relock off for the next unlock.
|
||||
- Header lock/unlock button and the `parental-lock-now` /
|
||||
`parental-unlock` palette commands.
|
||||
|
||||
## Startup footprint
|
||||
|
||||
The lock service, lock store and enforcement are on the renderer's initial
|
||||
path by necessity (the workspace resolver and the catalog data sources gate
|
||||
on them). Everything else stays lazy: the PIN dialog loads through
|
||||
`parental-lock-pin-dialog.lazy.ts` on the first prompt (a failed chunk load
|
||||
reads as a cancelled prompt), and the enforcement's Stalker step loads
|
||||
through `parental-lock-stalker-enforcement.ts` only while a Stalker route is
|
||||
open — a static import of the Stalker store would put the whole Stalker
|
||||
data layer back into `main.js`. The chunk is preloaded when a Stalker route
|
||||
opens, so a relock there runs the step synchronously; if it is not loaded
|
||||
yet (still fetching, or it cannot load — a stale PWA page after a
|
||||
deployment) the step fails closed at once by navigating to
|
||||
`/workspace/sources`: leaving the Stalker route clears its selection and
|
||||
stops its playback, and the Xtream step still runs. The feature costs about 35 KB of
|
||||
`renderer.initialBytes`; the baseline was not raised for it, the growth was
|
||||
offset by moving lazy-only barrel re-exports (backup/restore and the portal
|
||||
helpers) off the initial path.
|
||||
|
||||
## Lock store lifetime
|
||||
|
||||
On Electron the SQLite `categories.locked` index is derived from the lock
|
||||
store and must never lag behind it, because the worker filters every read
|
||||
by the index alone:
|
||||
|
||||
- The store commits BEFORE the re-stamp, so a failed re-stamp rolls the
|
||||
store back to the previous locks AND re-stamps every touched type from
|
||||
them (a backup restore stamps three types, and the ones before the
|
||||
failing type already carry the new locks). The store revision consumers
|
||||
reload on is published only once every touched type is stamped — also
|
||||
after a rollback — so a reload cannot read a later type through its old
|
||||
stamps. If the rollback write or its re-stamp fails too, the playlist is
|
||||
marked stale and re-stamped on the next store access. A failed rollback
|
||||
WRITE still puts memory back to the previous locks and leaves a pending
|
||||
rewrite: the next store access persists them before it re-stamps, so the
|
||||
failed edit cannot take effect through that re-stamp. The pending rewrite
|
||||
lives in memory: if the app exits before it lands, the next launch loads
|
||||
the persisted copy, i.e. the edit the parent submitted with the PIN. That
|
||||
is accepted — it is not a bypass, and a durable rollback journal would
|
||||
hit the same storage failure. For the whole
|
||||
re-stamp window — store changed, stamps not yet landed — the playlist
|
||||
counts as stale, so `readable` is false and a relock inside the window
|
||||
reloads fail-closed instead of through the old stamps. Such in-flight
|
||||
entries are not retried by a reconcile running beside the write (a PIN
|
||||
prompt or backup calling `ensureReadable()`): it would stamp from a store
|
||||
the write has not committed yet. Only failed stamps are retryable.
|
||||
- A write that removes a playlist's LAST lock clears the index first and
|
||||
drops the key afterwards: the launch-time reconcile finds playlists only
|
||||
through their key, so an interruption must leave store-with-lock and
|
||||
index-without, which the next reconcile repairs toward locked. If the
|
||||
clear or the store write fails, the index is re-stamped from the previous
|
||||
locks at once — title matching and multi-source discovery query the
|
||||
worker directly and trust the index, so a stale flag alone would not
|
||||
protect them. The playlist stays stale until the store write has landed
|
||||
too: while it is pending the index is already cleared but the store
|
||||
still holds the lock.
|
||||
- An edit that takes a lock away commits only while the session is
|
||||
unlocked, checked inside the write queue at commit time
|
||||
(`ParentalLockLockStore.setRemovalGate`, set by `ParentalLockService`).
|
||||
An editor opened while unlocked may still be saving, or queued behind
|
||||
another write, when the app relocks. The gate is asked right before the
|
||||
edit's first write is issued; a relock that lands after that is ordered
|
||||
after the write, which completes: the parent authorized that removal,
|
||||
and a post-write rollback could itself fail and leave the persisted
|
||||
store diverged from memory across a restart. (During the Xtream stamps
|
||||
the playlist is stale anyway, so the relock reloads fail-closed until
|
||||
they land.) Adding locks is always allowed.
|
||||
Playlist deletion and "Remove all playlists" are not edits and are not
|
||||
gated.
|
||||
- Every launch re-derives the index from the store for each playlist that
|
||||
has locks, and a store recovered by a later successful read marks its
|
||||
playlists stale the same way. The reconcile is awaited inside the store's
|
||||
`load()`, so `readable` (and with it every catalog read the renderer
|
||||
gates) stays false until the index agrees with the store; a re-stamp that
|
||||
keeps failing keeps the session fail-closed.
|
||||
|
||||
Every lock-store mutation runs through one write queue in
|
||||
`ParentalLockLockStore`: each rewrites the whole persisted store from the
|
||||
in-memory copy, so overlapping edits (two right-click toggles, a dialog
|
||||
save during a restore) would otherwise snapshot the same store and the
|
||||
later write would drop the earlier edit. Single-row changes (the
|
||||
right-click Lock/Unlock) are passed as an EDIT of the current list
|
||||
(`LockListEdit`), evaluated inside that queue, so two quick toggles each
|
||||
see the other's result instead of one shared snapshot; the dialogs pass a
|
||||
full list, which is the replacement they mean. A deleted playlist's locks leave
|
||||
the store through the `PLAYLIST_DELETE_CLEANUP` hook
|
||||
(`provideParentalLockPlaylistCleanup`, run by
|
||||
`PlaylistsService.deletePlaylist` for every single-playlist deletion; no
|
||||
re-stamp, the category rows go with the playlist), and "Remove all
|
||||
playlists" empties it through `ParentalLockService.clearAllLocks` once the
|
||||
deletion has succeeded — the in-memory store empties at once, and a failed
|
||||
persisted clear is retried on the next store access (the store is not
|
||||
`readable` until it lands). A backup restore that CREATES a playlist (not a merge) starts
|
||||
that playlist from empty locks, so a reused id can never inherit entries a
|
||||
failed cleanup left behind; the restore retries a failed lock-store read
|
||||
first (`ensureLocksReadable`) and aborts while it still fails, so that check
|
||||
never runs against the empty fail-closed snapshot.
|
||||
|
||||
## Backup
|
||||
|
||||
A backup carrying lock lists replaces the matching playlists' locks,
|
||||
possibly with an emptier set, so the import asks for the PIN first (after
|
||||
the file was chosen) and aborts when it is refused. That answer can go
|
||||
stale during a long import (idle relock, "Lock now"), so an entry whose
|
||||
restore would take a lock away asks again right before it replaces locks
|
||||
if the app has relocked; a refusal fails that entry and keeps its previous
|
||||
locks. A restore that only adds locks is not asked again. Locks are
|
||||
restored LAST
|
||||
for each entry, after the Xtream data restore, so a
|
||||
failed merge leaves the playlist's previous locks in place. M3U group titles
|
||||
travel verbatim (`normalizeParentalLockGroupTitles`, exact
|
||||
dedup): locks match `channel.group.title` exactly, so the trimming
|
||||
`uniqueStrings` used for favorites would weaken a lock on a title with
|
||||
surrounding whitespace. A backup's lock lists are validated entry by entry
|
||||
on import (`isWellFormedParentalLock*` in the shared contract): restore
|
||||
treats them as authoritative and the normalizer drops what it does not
|
||||
understand, so a damaged list is rejected rather than erasing the
|
||||
playlist's persisted protection.
|
||||
|
||||
`lockedGroupTitles` (M3U), `lockedCategories` (Xtream `{categoryType,
|
||||
xtreamId}`, Stalker `{categoryType, categoryId}`) travel in each entry's
|
||||
`userState`, written only when the playlist has locks. Absent means "no
|
||||
opinion" and restore leaves the target's locks alone; present replaces them
|
||||
(`replacePlaylistLocks`, which also re-stamps the Xtream index). The PIN is
|
||||
never exported.
|
||||
|
||||
## Not yet covered (phase 2)
|
||||
|
||||
Favorites, recently viewed, playback positions / Continue Watching, the
|
||||
dashboard rails built from Stalker and M3U documents, global favorites /
|
||||
recent pages, the EPG guide, downloads and recordings lists, the remote
|
||||
control's channel-number resolvers, the command palette's channel search and
|
||||
the Electron-only global search page (`/workspace/search`, whose stored
|
||||
results are not re-run on a relock) still show rows from locked categories. The SQLite-side hooks exist
|
||||
(`unlockedCategoryCondition()` / `unlockedCategorySql()`), the renderer
|
||||
predicate is `ParentalLockService.is*Locked`.
|
||||
|
||||
## Files
|
||||
|
||||
- Contracts: `libs/shared/interfaces/src/lib/parental-lock.util.ts`,
|
||||
`parental-lock-pin.util.ts`, `Settings.parentalLock*`,
|
||||
`PARENTAL_LOCK_SET_STATE`.
|
||||
- Renderer: `libs/services/src/lib/parental-lock/` (`ParentalLockService`
|
||||
owns the session state and PIN flows; `ParentalLockLockStore` owns the
|
||||
persisted lock set, its unreadable state and the SQLite re-stamp),
|
||||
`apps/web/src/app/services/parental-lock-prompt.service.ts`,
|
||||
`parental-lock-enforcement.service.ts`,
|
||||
`apps/web/src/app/settings/settings-parental-section.component.*` and `settings-parental-lock.facade.ts` (rows anchored and indexed for settings search),
|
||||
`libs/ui/components/src/lib/parental-lock-pin-dialog/`,
|
||||
`libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/`.
|
||||
- Electron: `apps/electron-backend/src/app/database/parental-lock-state.ts`,
|
||||
`services/parental-lock-state.ts`, `events/parental-lock.events.ts`,
|
||||
`database/operations/category.operations.ts` (`setCategoryLocks`).
|
||||
@@ -169,8 +169,9 @@ harness, which is what the ratchet needs. The main process start
|
||||
|
||||
`journeys.<id>.counters.<name>` and `journeys.<id>.wallClock.<name>` are plain
|
||||
numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with
|
||||
`tools/performance/journey-baselines.json`. A J1 baseline is added once the
|
||||
numbers are stable on the CI runner; until then the summary is evidence only.
|
||||
`tools/performance/journey-baselines.json`. A J1 runtime baseline is added
|
||||
once its counter is deterministic on the CI runner; the launch counters are
|
||||
not yet (see [Ratchet](#ratchet)), so the summary is evidence only.
|
||||
|
||||
## `renderer.initialBytes`
|
||||
|
||||
@@ -273,6 +274,17 @@ the first measurements was otherwise `package.json` text embedded in
|
||||
`main.js`, which moved with every script edit; #1692 fixed that by importing
|
||||
only the version.)
|
||||
|
||||
Two effects make the exact counter move for reasons outside a PR's own diff.
|
||||
A baseline lowered on a branch that predates a concurrent `master` merge can
|
||||
sit below what the merged code measures: #1712 lowered it on a branch without
|
||||
#1714, so `master` measured 108 bytes over and every later PR failed the job
|
||||
until a follow-up moved lazy-only modules out of `main.js`. Re-run the job on
|
||||
an up-to-date branch before merging a baseline change. And the bundler's
|
||||
chunk-level identifier renaming shifts when a module enters or leaves
|
||||
`main.js`: moving one service out once renamed an imported identifier at 162
|
||||
call sites, eating about 320 of the bytes saved. Judge a small change by the
|
||||
`--stats-json` input sizes, not only by the counter.
|
||||
|
||||
The job also refuses a weakened baselines file:
|
||||
`tools/performance/check-baseline-direction.mjs` compares
|
||||
`journey-baselines.json` with the revision the change is measured against
|
||||
@@ -287,6 +299,36 @@ earned it, set `updatedAt` and `evidencePr`, and paste the measurement output
|
||||
into the PR. Never raise a value to make a PR pass: if growth is a deliberate
|
||||
trade-off, say so in the PR and let the maintainer decide.
|
||||
|
||||
The runtime counters come from the `Performance journeys` job of the same
|
||||
workflow, on `ubuntu-latest` only. It runs `pnpm run perf:journeys` under
|
||||
`xvfb-run` (the Nx target builds `electron-backend:build-performance`, the
|
||||
Playwright config starts the Xtream mock), writes the measurements to the job
|
||||
summary and uploads `dist/performance/journeys/` as the `performance-journeys`
|
||||
artifact. The `Performance journeys scope` job skips it only for pull
|
||||
requests that change nothing but Markdown, `docs/**`, `.plans/**`,
|
||||
`.codex/**`, `.claude/**`, `.changes/**` or `apps/website/**` (the E2E
|
||||
workflow's ignore list plus release notes); any other file, including root
|
||||
build inputs such as `.nvmrc`, `nx.json` or `tsconfig.base.json`, runs it.
|
||||
Pushes to `master` and manual dispatches always run it. The job is warn-only (`continue-on-error: true`) for its first two
|
||||
weeks (plan item B3): a regression marks the job failed without failing the
|
||||
workflow. Making it required is a maintainer decision.
|
||||
|
||||
No J1 runtime counter is enforced yet. Three dispatched runs on 2026-09-27
|
||||
(CI runs 36271875209, 36271879955 and 36271884616) reported the same summary
|
||||
values, `renderer.ipcCallsToFirstCard` 16 and
|
||||
`renderer.domMutationsToFirstCard` 939, but the third run marked both
|
||||
`stable: false`: its warm-up and one measured iteration reached the first
|
||||
card in about 750 ms with 13 bridge calls and 576 mutations, the others in
|
||||
about 1,400 ms with 16 and 939. The three extra calls
|
||||
(`downloadsGetDefaultFolder` and two `dbGetGlobalRecentlyAdded`) land before
|
||||
or after the first card depending on that race, so neither counter is
|
||||
promoted until the race is understood and the counters are deterministic.
|
||||
`renderer.layoutShiftScore` (0) and `renderer.longTasks` (2) were identical
|
||||
in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from
|
||||
1,401 to 1,674 ms. All four stay evidence for now. Runner counters also
|
||||
differ from a Mac (12 and 571 there, the fast path without the Linux-only
|
||||
`getWindowState` call), so take J1 baseline values from the runner only.
|
||||
|
||||
## Adding a counter
|
||||
|
||||
1. Produce the value from the built output or from a deterministic probe, not
|
||||
|
||||
@@ -27,12 +27,36 @@ using its result as evidence. Docker validation can use
|
||||
| ---------------------------------- | ----------------------------------- |
|
||||
| Angular renderer entry points | `pnpm run typecheck:web` |
|
||||
| Electron main process entry points | `pnpm run typecheck:backend` |
|
||||
| Jest spec programs | `pnpm run typecheck:spec` |
|
||||
| Full unit suite (all projects) | `pnpm run test:unit:ci` |
|
||||
| EPG data access | `pnpm nx test epg-data-access` |
|
||||
| Workspace shell utilities | `pnpm nx test workspace-shell-util` |
|
||||
| Shared SQLite schema/connection | `pnpm nx test database` |
|
||||
| Packaging metadata | `pnpm nx test packaging` |
|
||||
|
||||
`typecheck:spec` (`tools/typecheck/spec-typecheck.mjs`) runs `tsc --noEmit`
|
||||
over every `tsconfig.spec.json` under `apps/`, `libs/` and `tools/`, a few
|
||||
programs at a time (`--concurrency=N` or `SPEC_TYPECHECK_CONCURRENCY`; a
|
||||
positional argument filters by path), and fails on any diagnostic. ts-jest
|
||||
transpiles with `isolatedModules`, so this is the only check that catches a
|
||||
spec whose types drifted from the code it exercises. CI runs it in the
|
||||
`unit-and-typecheck` job after `typecheck:ci`. Conventions the gate relies on:
|
||||
|
||||
- Spec tsconfigs use `module: preserve` with `moduleResolution: bundler`, the
|
||||
same as the library's own `tsconfig.json`; ts-jest forces CommonJS emit
|
||||
outside ESM mode, so the setting only affects type-checking, and `node10`
|
||||
resolution cannot see Angular's `exports`-only secondary entry points.
|
||||
- Each spec program lists `global.d.ts` in `files` so `window.electron` and the
|
||||
other ambient declarations resolve.
|
||||
- Libraries tested through `tools/testing/run-web-esm-lib-tests.mjs` are
|
||||
type-checked by `apps/web/tsconfig.spec.json`, the config
|
||||
`jest.web-esm.workspace.ts` hands to ts-jest; add a new ESM-tested library's
|
||||
spec globs there. `apps/web/src/jest-esm.d.ts` types `jest.unstable_mockModule`.
|
||||
- Type test doubles instead of casting to `any`: `jest.Mocked<T>`,
|
||||
`InstanceType<typeof SomeStore>` for signal stores, and
|
||||
`Object.defineProperty` or a writable mapped type for read-only capability
|
||||
flags.
|
||||
|
||||
## Lint
|
||||
|
||||
```bash
|
||||
@@ -78,10 +102,10 @@ count per project (defaults: `min(3, cores - 1)` in flight and
|
||||
project's output is printed as one block when it finishes, and the run ends
|
||||
with the wall-clock total and the longest projects. Spec `tsconfig`s set
|
||||
`isolatedModules: true`, so ts-jest transpiles files one at a time instead of
|
||||
type-checking each through a language service; spec type errors therefore do
|
||||
not fail Jest (the web configs already ran with `diagnostics: false`), while
|
||||
`isolatedModules`-incompatible syntax such as a type re-export without
|
||||
`export type` still fails at load time.
|
||||
type-checking each through a language service (the web configs already ran
|
||||
with `diagnostics: false`); `isolatedModules`-incompatible syntax such as a
|
||||
type re-export without `export type` still fails at load time, and spec type
|
||||
errors are caught by `typecheck:spec` (see Unit And Type Checks).
|
||||
|
||||
In CI, a pull request skips the Tier A suite (and the merged-coverage upload)
|
||||
when every changed file is outside Tier A test inputs:
|
||||
|
||||
@@ -52,7 +52,10 @@ Current workspace routes:
|
||||
8. `/workspace/search`
|
||||
9. `/workspace/downloads`
|
||||
10. `/workspace/settings/:section` (`/workspace/settings` redirects to
|
||||
`general`; the settings context panel links each section page)
|
||||
`general`; the settings context panel links each section page; sections:
|
||||
`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`,
|
||||
`parental` — see [parental lock](parental-lock.md) — `backup`, `reset`,
|
||||
`about`)
|
||||
11. `/workspace/xtreams/:id/...`
|
||||
12. `/workspace/stalker/:id/...`
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ are not prerequisites for reading repository contracts.
|
||||
| --- | --- | --- |
|
||||
| Bootstrap, project placement, dependencies, aliases and lint configuration; root Nx config and project-local project.json files | [Nx boundaries](../architecture/nx-workspace-boundaries.md), [security overrides](../architecture/dependency-security-overrides.md) | [Nx architecture](../../.codex/skills/iptvnator-nx-architecture/SKILL.md) |
|
||||
| Angular conventions; docs and skills maintenance | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
|
||||
| Unit, E2E, lint and coverage; `tools/coverage` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
|
||||
| Unit, E2E, lint and coverage; `tools/coverage`, `tools/typecheck` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
|
||||
| Performance journeys, counters, benchmark probes and the CI ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance`, `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
|
||||
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
|
||||
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |
|
||||
@@ -39,6 +39,7 @@ are not prerequisites for reading repository contracts.
|
||||
| Live panels, keyboard focus, grid/layout conventions; shared UI and portal views | [UI guidelines](../architecture/iptvnator-ui-guidelines.md), [detail navigation](../architecture/portal-detail-navigation.md) | [UI design](../../.codex/skills/iptvnator-ui-design/SKILL.md), [theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) |
|
||||
| Workspace routes, title bar, switcher, collections and dashboard; `libs/workspace` | [Workspace shell](../architecture/workspace-shell.md), [dashboard](../architecture/workspace-dashboard.md), [collection/detail navigation](../architecture/portal-detail-navigation.md) | UI/theme skills for visible changes |
|
||||
| Remote control, playback queue, channel return and shortcuts; `libs/ui/remote-control`, `apps/remote-control-web` | [Remote control](../architecture/remote-control.md) | Provider skill when queue ownership changes |
|
||||
| Parental lock: PIN, per-category locks, worker-side filtering; `libs/services/src/lib/parental-lock`, category/group dialogs, `apps/electron-backend/src/app/database/parental-lock-state.ts` | [Parental lock](../architecture/parental-lock.md), affected provider contract | Read the affected provider skill |
|
||||
| Downloads, offline details, catch-up and file availability; `libs/portal/downloads` | [Download manager](../architecture/download-manager.md), provider contract for URL resolution | Read the affected provider skill |
|
||||
| VOD source discovery, factual metadata and failover; `libs/portal/shared/data-access` | [VOD multi-source](../architecture/vod-multi-source.md) | [Xtream](../../.codex/skills/xtream-electron/SKILL.md) |
|
||||
| TMDB enrichment, artwork, actors and recommendations; `libs/services/src/lib/tmdb` | [TMDB contracts](../architecture/tmdb-metadata-enrichment.md), [dashboard](../architecture/workspace-dashboard.md) | UI skill for rendering changes |
|
||||
|
||||
Reference in new issue
Block a user