From 6b1a321c9d0eb62c168c0cff13953d0622bcb3ab Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:18:12 +0200 Subject: [PATCH 1/7] ci(codeql): cancel superseded pull-request analyses (#1718) PR CodeQL runs now share a per-PR concurrency group with cancel-in-progress; master pushes, the weekly schedule and manual dispatches get a unique group and are never cancelled. 69 superseded analyses ran to completion across 19 branches in the day before this change. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/codeql-analysis.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 93b8e120f..74e397a8f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,6 +15,15 @@ on: schedule: - cron: '0 20 * * 3' +# A newer push to a pull request cancels that PR's still-running analysis: +# only the latest commit's result matters, and superseded runs otherwise hold +# runners the rest of the pipeline is queued for. Pushes to master, the weekly +# schedule and manual dispatches get a unique group (run_id), so they are never +# cancelled or replaced, the same pattern as ci.yml. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + # Required so `codeql-action/analyze` can upload its SARIF results. Without an # explicit grant the default token is read-only and the upload fails with # "Resource not accessible by integration". From f0e51d2806fd99cc618f28ee5322d913848d0305 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:21:40 +0200 Subject: [PATCH 2/7] perf(web): keep lazy-only services and SafePipe out of main.js (#1729) * perf(web): keep lazy-only services and SafePipe out of main.js The eager shell imported barrels that re-export Angular injectables and a pipe it never uses, and their static definitions keep those modules in main.js: PlaylistFileImportService came with PlaylistContextFacade, normalizeDateLocale with SafePipe, and the workspace-shell-util barrel with SettingsContextService, which #1714 grew with match counts. That growth put master 108 bytes over the renderer.initialBytes baseline #1712 had measured on a branch without #1714. Add file-level entries for the three modules and use them from the eager and settings code: renderer.initialBytes 1,626,127 -> 1,619,993 bytes. Co-Authored-By: Claude Opus 5.5 * chore(performance): lower the initial-bytes baseline to 1,619,993 bytes Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- apps/web/src/app/app-date-locales.ts | 2 +- .../app/services/playlist-open-request.service.ts | 2 +- .../src/app/settings/settings-search.facade.ts | 2 +- .../settings/settings.component.search.spec.ts | 2 +- apps/web/src/app/settings/settings.component.ts | 2 +- docs/architecture/nx-workspace-boundaries.md | 15 +++++++++++---- docs/architecture/performance-journeys.md | 11 +++++++++++ .../epg-progress-panel.component.ts | 2 +- ...space-settings-context-panel.component.spec.ts | 2 +- .../workspace-settings-context-panel.component.ts | 6 ++---- libs/workspace/shell/util/src/index.ts | 1 - tools/performance/journey-baselines.json | 4 ++-- tsconfig.base.json | 9 +++++++++ 13 files changed, 42 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/app-date-locales.ts b/apps/web/src/app/app-date-locales.ts index df69e39cb..54c58d7df 100644 --- a/apps/web/src/app/app-date-locales.ts +++ b/apps/web/src/app/app-date-locales.ts @@ -2,7 +2,7 @@ import { registerLocaleData } from '@angular/common'; import localeEn from '@angular/common/locales/en'; import { inject, Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { normalizeDateLocale } from '@iptvnator/pipes'; +import { normalizeDateLocale } from '@iptvnator/pipes/date-format'; import { createDevLogger } from '@iptvnator/shared/interfaces'; type LocaleDataModule = { default: unknown }; diff --git a/apps/web/src/app/services/playlist-open-request.service.ts b/apps/web/src/app/services/playlist-open-request.service.ts index 06af1781b..6b1be9fcc 100644 --- a/apps/web/src/app/services/playlist-open-request.service.ts +++ b/apps/web/src/app/services/playlist-open-request.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; -import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util'; +import { PlaylistFileImportService } from '@iptvnator/playlist/shared/util/playlist-file-import'; import type { ElectronBridgePlaylistOpenRequest } from '@iptvnator/shared/interfaces'; type PlaylistOpenRequestBridge = { diff --git a/apps/web/src/app/settings/settings-search.facade.ts b/apps/web/src/app/settings/settings-search.facade.ts index 5d8c2ce55..036c80b73 100644 --- a/apps/web/src/app/settings/settings-search.facade.ts +++ b/apps/web/src/app/settings/settings-search.facade.ts @@ -12,7 +12,7 @@ import { import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; import { SettingsRevealRequest, SettingsSearchEntry, diff --git a/apps/web/src/app/settings/settings.component.search.spec.ts b/apps/web/src/app/settings/settings.component.search.spec.ts index 197ffd3e4..075357c25 100644 --- a/apps/web/src/app/settings/settings.component.search.spec.ts +++ b/apps/web/src/app/settings/settings.component.search.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { Router } from '@angular/router'; -import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; import { SETTINGS_SEARCH_ENTRIES, SettingsSearchEntry, diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index e66083e33..9e0c9c004 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -16,7 +16,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { ActivatedRoute, Router } from '@angular/router'; -import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { EpgSourceReconciliationError, diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 13bc2fe5a..95c2ece24 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -235,18 +235,25 @@ 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. 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, not the library alias, is the dynamic-import target because diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 05ae0e4e0..e15aea3bd 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -273,6 +273,17 @@ the first measurements was otherwise `package.json` text embedded in `main.js`, which moved with every script edit; #1692 fixed that by importing only the version.) +Two effects make the exact counter move for reasons outside a PR's own diff. +A baseline lowered on a branch that predates a concurrent `master` merge can +sit below what the merged code measures: #1712 lowered it on a branch without +#1714, so `master` measured 108 bytes over and every later PR failed the job +until a follow-up moved lazy-only modules out of `main.js`. Re-run the job on +an up-to-date branch before merging a baseline change. And the bundler's +chunk-level identifier renaming shifts when a module enters or leaves +`main.js`: moving one service out once renamed an imported identifier at 162 +call sites, eating about 320 of the bytes saved. Judge a small change by the +`--stats-json` input sizes, not only by the counter. + The job also refuses a weakened baselines file: `tools/performance/check-baseline-direction.mjs` compares `journey-baselines.json` with the revision the change is measured against diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts index 9b633a84f..d0e47b8e9 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts @@ -20,7 +20,7 @@ import { EpgProgressService, } from '@iptvnator/epg/data-access'; import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces'; -import { normalizeDateLocale } from '@iptvnator/pipes'; +import { normalizeDateLocale } from '@iptvnator/pipes/date-format'; import { formatEpgImportDisplayUrl } from './epg-import-display-url'; interface EpgTrustConfirmDialogData { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts index ec6ee1e5d..effb04e5c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component'; describe('WorkspaceSettingsContextPanelComponent', () => { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index 92448563f..ced7aef8b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -3,10 +3,8 @@ import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { - WorkspaceShellContextDrawerService, - SettingsContextService, -} from '@iptvnator/workspace/shell/util'; +import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; @Component({ selector: 'app-workspace-settings-context-panel', diff --git a/libs/workspace/shell/util/src/index.ts b/libs/workspace/shell/util/src/index.ts index fee274af0..673c6f2c4 100644 --- a/libs/workspace/shell/util/src/index.ts +++ b/libs/workspace/shell/util/src/index.ts @@ -1,4 +1,3 @@ -export * from './lib/settings-context.service'; export * from './lib/workspace-shell-context-drawer.service'; export * from './lib/navigation/workspace-shell-route.utils'; export * from './lib/navigation/workspace-search-capability'; diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index 1e690f08f..e6fbd58da 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -3,10 +3,10 @@ "journeys": { "launch": { "renderer.initialBytes": { - "value": 1626019, + "value": 1619993, "unit": "bytes", "updatedAt": "2026-09-27", - "evidencePr": 1712, + "evidencePr": 1729, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 828f042d5..84bf54a8b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -43,6 +43,9 @@ "@iptvnator/playlist/shared/util": [ "libs/playlist/shared/util/src/index.ts" ], + "@iptvnator/playlist/shared/util/playlist-file-import": [ + "libs/playlist/shared/util/src/lib/playlist-file-import.service.ts" + ], "@iptvnator/ui/epg": ["libs/ui/epg/src/index.ts"], "@iptvnator/ui/epg/date": ["libs/ui/epg/src/lib/epg-date.ts"], "@iptvnator/ui/epg/progress-panel": [ @@ -79,10 +82,16 @@ "@iptvnator/workspace/shell/util": [ "libs/workspace/shell/util/src/index.ts" ], + "@iptvnator/workspace/shell/util/settings-context": [ + "libs/workspace/shell/util/src/lib/settings-context.service.ts" + ], "@iptvnator/workspace/shell/util/settings-search": [ "libs/workspace/shell/util/src/lib/settings-search/index.ts" ], "@iptvnator/pipes": ["libs/ui/pipes/src/index.ts"], + "@iptvnator/pipes/date-format": [ + "libs/ui/pipes/src/lib/date-format.util.ts" + ], "@iptvnator/ui/components": ["libs/ui/components/src/index.ts"], "@iptvnator/ui/components/confirm-dialog": [ "libs/ui/components/src/lib/confirm-dialog/dialog.service.ts" From e45cd85a78e83911fdbbc2329a30aef8096f8f9b Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:38:34 +0200 Subject: [PATCH 3/7] feat(settings): PIN-protected parental lock for categories (#285) (#1601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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