Registering IPC handlers costs 0.4 ms; evaluating the modules behind them (axios, drizzle-orm, better-sqlite3, electron-updater, fix-path) before the window could load was the real cost. main.ts now keeps only the pre-paint wiring and loads the rest as the deferred-events.js chunk inside the main window's did-start-loading listener, where the import and its registrations complete before any renderer invoke can arrive.
Interleaved A/B on the performance build: app.whenReady 323 -> 265 ms, did-finish-load 499 -> 447 ms; J1 journey spawnToDidFinishLoad ~405 -> ~365 ms with identical counters. Packaging ships the chunk explicitly, verify:package-layout requires it, and the benchmark build identity hashes it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Run the sequential Electron E2E suite as three Playwright shards per OS
(one runner each) and summarize all shards of an OS in one follow-up job.
The semantic summary script accepts a directory of shard reports, merges
them and refuses to write when a shard is missing, duplicated or
malformed, or when an explicit input does not exist.
Slowest shard per OS in the final run: ubuntu 12.5 min (was 26),
macOS 13.7 min (was 34), Windows 24.5 min (was 35).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main.js is now a small entry that enables Node's on-disk V8 compile cache under userData/v8-compile-cache and then requires the application bundle main.app.js. Warm launches reach app.whenReady about 13 ms sooner at the median; IPTVNATOR_DISABLE_COMPILE_CACHE=1 turns it off and IPTVNATOR_COMPILE_CACHE_DIR relocates it.
Packaging now ships main.app.js explicitly and verify:package-layout requires the main-process entry files in app.asar, because nx-electron copies the backend through an allowlist. The Xtream benchmark build identity hashes both the launcher and the bundle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(performance): add the J1 launch-to-usable journey benchmark
Implements plan items A1, A3 (J1 only) and the minimal A4 from
.plans/2026-09-25-performance-journeys-ratchet.md.
- journey-renderer-probe.ts: init-script probe counting DOM mutations,
layout shifts and long tasks until the first source card is visible on
/workspace with the splash removed; unit-tested with jsdom fixtures.
- journey-main-ipc-capture.ts: counts bridge invocations from the preload's
renderer-API trace channel up to a sentinel call the probe fires, so the
IPC counter is exact without touching production code.
- launch.journey.ts + playwright.journeys.config.ts: seeded profile (one
M3U source, one Xtream portal on the loopback mock), one warm-up and five
measured iterations, fresh process and data directory each, writing
dist/performance/journeys/<timestamp>/summary.json with exact counters
and P50/P90 wall-clock.
- Nx target electron-backend-e2e:journeys and root script perf:journeys.
- docs/architecture/performance-journeys.md, README, context and
validation map entries.
renderer.cdTicksToFirstCard and main.sqlStatementsBeforeReadyToShow are
reported as unavailable with the reason instead of being faked.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(performance): gate the renderer load so the probes never race startup
Review follow-up for #1698.
- journey-renderer-gate.cjs: a main-process hook loaded with `-r` (the
mechanism Playwright uses for its own loader) makes the first
loadFile/loadURL navigate to about:blank and holds the real load until
the test releases it. Playwright reports no page before a navigation
commits, so this is what lets the renderer probe be registered on the
page before the real document exists; the IPC capture is installed
before the release too. A safety timeout releases the gate on its own
and marks the iteration invalid. Unit-tested with a fake BrowserWindow.
- launch-journey-app.ts: registers the probe on the parked page, releases
the gate, waits for the real document to commit, fails fast when the
probe is missing, and refuses an iteration whose gate timed out, saw a
second load, or released before the probe was in place.
- journey-renderer-probe.ts: entries delivered live after the terminal
batch are buffered and filtered by the same cutoff as queued ones, and
the cutoff is sampled in a timer queued from the first rAF, i.e. after
the card's frame is painted, so the render task's long task and layout
shift are consistently included.
- launch-journey-record.ts: layoutShiftScore rounded to three decimals; a
0.0001 shift flipped in and out of the cutoff between iterations.
- playwright.journeys.config.ts: reuse a mock server left on the journeys
port locally (its fixtures are deterministic); CI still starts its own.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(performance): validate the journey gate after the probe completes
Codex follow-up on #1698: the gate state returned by release() cannot see a
reload or recovery navigation that happens before the first card. Re-read the
live state once the renderer probe has finished and validate that instead,
so an iteration spanning an extra navigation is rejected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(performance): fail an iteration whose performance observers were unavailable
Codex follow-up on #1698: a renderer that cannot observe layout-shift or
longtask entries used to pass the probe with zero counters, which a ratchet
could not tell apart from a genuine zero. The probe assertion now rejects
such an iteration and names the missing observer.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
- 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>
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>
Plan thread C1, journey **J1 `launch`**, counter **`renderer.initialBytes`**. Stacked on #1694 → #1693 → #1692; merge in order.
`apps/web/src/app/app-date-locales.ts` imported the locale data of all 18 supported languages eagerly, so every user shipped and parsed all of it at startup. Each locale is now a dynamic import keyed by the Angular locale id that `normalizeDateLocale()` derives from the app language (`by` → `be`, `ary` → `ar-MA`, `zhtw` → `zh-Hant`); English needs no data.
Ordering is preserved so no template renders a locale whose data has not arrived (Angular throws in that case):
- `main.ts` awaits the initial language's data (from `getInitialLanguage()`) before `bootstrapApplication`.
- Both `TranslateService.use()` call sites, `AppComponent.initSettings()` and `SettingsFormFacade.applySavedSettings()`, register the data first through the new `AppDateLocaleService`.
- A failed load never leaves the locale without data: English formatting is registered under the requested id (eager 1.1 KB `@angular/common/locales/en`), so `DatePipe` renders instead of throwing; the locale is not marked registered, so the next call retries and a success replaces the fallback (review follow-up).
- `AppDateLocaleService.use()` orders switches by request, not by completion: a switch whose data arrives after a newer request is dropped, so the language chosen last wins (review follow-up).
No kill switch: behavior is identical once the locale resolves, and the only new failure mode (a same-origin chunk failing to load) is shared with every lazy route.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Third step of the performance-journeys ratchet, stacked on #1693 (which is stacked on #1692; merge in order, GitHub retargets each to `master`).
- New `Initial bytes ratchet` job in `.github/workflows/ci.yml` (ubuntu-latest): install, `pnpm nx build web --skip-nx-cache` (production configuration, the one users download), then `pnpm run perf:initial-bytes:check`. The job fails when `renderer.initialBytes` exceeds `tools/performance/journey-baselines.json`.
- `dist/performance/` is uploaded as the `performance-journey-summary` artifact on every run, so a failing or tightenable run carries its evidence.
- After review: the job first runs the new `tools/performance/check-baseline-direction.mjs`, which compares `journey-baselines.json` with the revision the change is measured against (the target branch of a pull request, `github.event.before` for a `master` push, `master` for a manual dispatch) and fails on any raised enforced limit (`value × toleranceRatio`), any widened or newly added tolerance, or any removed entry, so a PR cannot grow the payload and raise the baseline to match (lowered limits and new entries pass; a target branch without the file has nothing to weaken). Node tests cover it.
- Docs: the performance-journeys contract and the validation map name the job, and the contract now states that this runner is the canonical measurer (take baseline values from its output, not from a local build).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Second step of the performance-journeys ratchet, stacked on #1692 (merge that first; this PR retargets to `master` automatically).
- `tools/performance/journey-baselines.json`: J1 `launch` / `renderer.initialBytes` = **2,739,510 bytes**, the ubuntu runner's production build of `apps/web` at this content (after #1692 stopped bundling `package.json` into `main.js`). A local macOS build of this pre-#1695 code is 2 bytes smaller in `main.js` (the eager locale imports); once #1695 removes them the two are byte-identical. Correction to an earlier version of this description: the "556-byte macOS vs Linux difference" was almost entirely `package.json` text embedded in `main.js`, which moved with every script edit in this stack, plus this 2-byte residue. The runner is the canonical measurer; the CI run on the stacked #1694 branch (this content plus the job) is where the number is confirmed.
- `tools/performance/check-journey-ratchet.mjs` compares a journey summary with the baselines: a counter above its value fails (exact, no slack), wall-clock entries fail above `value × toleranceRatio`, a baseline without a measurement fails so dropping a measurement cannot disable the ratchet, values below baseline print a "tighten" hint, and measured counters without a baseline are noted only. After review: checking nothing (empty file, or `--only` naming a missing entry) fails; a counter is read only from `counters` and a wall-clock entry only from `wallClock`; the repeatable `--only <journey>/<counter>` flag scopes a check.
- Root scripts: `perf:initial-bytes:check` (measures into its own `dist/performance/initial-bytes.summary.json`, then checks `--only launch/renderer.initialBytes`) and `perf:ratchet:check` (full check); `perf:tools:test` runs both test files, as does `pnpm nx test performance-tools`.
- `docs/architecture/performance-journeys.md` gains the Ratchet section (file format, rules, "baselines only move down"); the validation map lists the check.
The CI job that runs the check on every PR is #1694; C1 (lazy Angular date locales, #1695) then lowers the baseline with the measured output as evidence.
Note: `ci.yml` only triggers on pull requests targeting `master`, so this stacked PR shows no Actions runs until #1692 merges. The evidence runs above were dispatched with `gh workflow run ci.yml --ref <branch>`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
First step of the performance-journeys ratchet (plan thread: J1 `launch`, counter `renderer.initialBytes`).
- `tools/performance/measure-initial-bytes.mjs` reads the built `dist/apps/web/index.html` and sums `index.html` plus every same-origin `<script src>`, `<link rel="stylesheet">` and `<link rel="modulepreload">` it references. Manifest, icons, external URLs and lazy chunks are not counted. A referenced file missing from the build fails the measurement instead of counting as zero bytes.
- `--json` prints the breakdown; `--summary <file>` writes the `journeys.<journey>.counters` shape a ratchet checker will consume (next PR).
- New Nx project `performance-tools` (test + lint targets), Tier B in `tools/coverage/coverage-policy.json`, root scripts `perf:initial-bytes` and `perf:tools:test`.
- New contract `docs/architecture/performance-journeys.md`, linked from the validation map, the agent context map and the README.
- **Review follow-ups:** resources are deduplicated by request URL (query kept, fragment dropped); `index.html` is parsed with parse5 (already a repository dependency, scripting enabled), so comments, bogus comments, raw-text bodies (script/style/noscript/title/textarea), inert `<template>` contents and character references in attributes all follow the HTML5 algorithm instead of a hand-written scanner; the review's edge cases stay as regression tests; docs show the `pnpm --silent` form for JSON output and explain how the counter relates to Angular's rounded "Initial total".
- **Found while measuring:** the environment files and the playback diagnostic panel imported the whole `package.json` (`import packageJson from '@package'`), which esbuild cannot tree-shake, so `main.js` carried the complete file and the counter moved with every script or dependency edit. They now import `{ version }` only (eb662c485): `main.js` shrinks by **11,539 bytes** and the counter no longer depends on `package.json`. Jest's ESM loader exposes JSON only as a default export, so the two web Jest configs map `@package` to a stub that serves the real file's fields as named exports. Release note: `.changes/web-version-only-from-package-json.md` (`type: perf`).
Production build after this PR measures **2,739,508 bytes** (11 files + `index.html`); Angular's "Initial total" is this minus `index.html` and `assets/app-config.js`. The baseline file and the CI check land in the follow-up PRs; C1 (lazy Angular date locales) then lowers it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- 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>
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>
- 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>
- 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>
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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
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>
- 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>
- 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>
- 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>
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>
- 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>
- 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>
Resolves the settings-store defaults split, the electron-conf key list and
the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental
lock contract is linked from the agent context map instead).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(electron): reap Windows process trees and retain smoke diagnostics
* test(electron): require confirmed process exit before relaunch
* test(electron): retain process handle after application exit
* test(electron): bind captured processes to application instances
* docs(tmdb): replace catalog titles in matching examples with stand-ins
The Cyrillic and Arabic examples that document title folding were taken
straight from a user's own portal catalog while the folding bugs were being
diagnosed, and they spread from there into comments, fixtures, assertions and
the architecture doc. A public repository is not the place for someone's
viewing inventory, and the TMDB ids pinned alongside them identify the exact
shows.
Swap in stand-ins that reproduce the property under test rather than the
title: a word whose "й" folds away, one whose "ё" does, a two-word title
carrying a season marker, an Arabic phrase whose initial hamza decomposes into
a separate word. Every replacement was run through the real `normalizeTitle`
and `cleanTitleForSearch` before being written, so the folded keys, the wire
queries and the cache lookup keys are the same shape as before — "Лейка" and
"Леика" still meet on one key while staying two different searches, and
"AR| أمثلة تجريبية" still keys as a hamza split into a space. Real TMDB ids
become synthetic ones. One channel fixture that read as a film title becomes a
plain channel name.
Deliberately left alone: the leading-tag rule's "Akira | 1988" / "Момо | Momo"
examples in `title-normalization.util.ts`. Those are not an inventory — they
are the evidence for a regex decision measured over 1.27M catalog titles, and
inventing replacements would document a measurement that never happened.
No release note: comments, fixtures and docs only, with no behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(tmdb): label the folding stand-ins as illustrative, not as history
Review caught that the substitution left synthetic titles inside sentences
that assert observed fact: the architecture doc claimed a particular folded
query "returns zero results while `Лейка` returns the show" and named the
stand-ins as the titles that were searched, missed and negatively cached, and
several comments read the same way. The titles never existed, so the reading
is wrong in the one direction that matters — a future reader debugging a
regression would take them for production evidence.
Keep the claim that is actually established, which is a class-level one:
under the old single-form design every Cyrillic title carrying "й"/"ё" and
every Arabic title carrying a hamza form was searched folded, missed, and
cached as missing for the negative TTL. Present the strings themselves as
illustrative stand-ins chosen to fold the same way. The verified invariant —
what NFD does to those letters, and what the two tiers therefore produce — is
unchanged and still assertable, because it is a property of the text rather
than of any title.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(tmdb): finish the sweep — a missed cache key and a last observed-fact claim
Two spots the first pass left behind:
- `tmdb-search-cache-cleanup.spec.ts` kept a catalog-derived lookup key
verbatim. Only the TMDB id beside it had been swapped, because the sweep
matched the title in its display casing and the key stores it lowercased —
so one item of the inventory stayed in the repository, twice.
- `SearchTitleVariant`'s doc comment still asserted that one specific folded
string finds nothing on TMDB. State the mechanism instead: folding rewrites
the letters the search matches on, so the folded form finds nothing there.
`content-search.util.sqlite.spec.ts` gets a channel fixture that no longer
reads as a film title.
`search-text-fold.util.spec.ts` is deliberately left alone. Its "Ёлки" is a
common noun sitting in a Unicode corpus beside "Amélie", "İnşaat" and "Ά",
not an inventory entry — and its rows are precomposed/decomposed PAIRS
(U+0401 against U+0415 U+0308). A textual substitution rewrites only the
composed half and silently turns the pair into two different words; doing it
failed that spec, which is what a fixture encoding an invisible property is
for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(search): finish the fold fixtures by rewriting both halves of the pair
The last two occurrences lived in the Unicode fold corpus, which an earlier
attempt left alone after a textual substitution failed the spec. The reason it
failed is the point: one row is a precomposed/decomposed PAIR — U+0401 against
U+0415 U+0308 — asserting that both spellings fold to one string. Replacing
the visible text rewrites only the composed half and silently turns the pair
into two different words, which is not a thing a reader or a regex can see.
Rewrite both halves by code point instead, keeping the decomposed half
decomposed: U+0415 U+0308 + the new stem. Verified by decoding every quoted
string in the file afterwards, and the spec passes (534/534).
A repository-wide sweep now finds no occurrence of the replaced titles in
either normalization form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The checked-in Windows Embedded MPV runtime pin pointed at an upstream release
whose 30-day retention had expired, so `Build on windows x64` failed
repository-wide the moment a runner's cache went cold:
Unable to download Windows embedded MPV runtime archive: 404 Not Found
The weekly refresh exists to rotate the pin well before that boundary, and it
had been red since 2026-09-07 — because its own validation step could only
pass while the pin was stale. `createPinFixture()` copied the CHECKED-IN pin
into the temp dir, and the age-threshold test then asserted, against a clock
frozen at 2026-09-05, that this pin was at least 14 days old. So every
successful rotation invalidated the assertion that guarded it: the step went
red, the bot pull request was never opened, and the pin was left to expire.
Build the refresh fixtures from the synthetic release fixture at an age the
test chooses (`createPinFixture({ ageDays })`) instead. The three refresh tests
are about the rule, so the rule is now what they exercise — one day under the
threshold is left alone, exactly at the threshold rotates, and the unavailable
case is deliberately young so only the 404 can explain it — and the outcome no
longer depends on production data that this job exists to replace.
`CURRENT_PIN` stays with the schema, naming and licence-statement checks, which
hold for any pin. A new case pins the invariant the job actually needs: a pin
written moments ago must read as current on the next run.
Rotate the pin to the newest upstream LGPL x86_64 release. Verified
end-to-end: the archive downloads (27 MB) and its SHA-256 matches the pinned
digest. The binary stays on its upstream host; IPTVnator does not mirror it and
the limited checksum/layout-only licence statement is preserved verbatim.
Also drops three catalog titles from a pending TMDB release note: notes are
published verbatim into CHANGELOG.md, the GitHub release body and the
announcement drafts, so the example now names the letter rather than the shows.
No release note: CI plumbing under `tools/`, outside the gate's `apps/`+`libs/`
trigger, with no user-visible behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Metadata is looked up in the app's own language, so an unrelated older foreign
series can come back under exactly the same localized name as a recent
local-language one. `pickConfidentMatch` admitted the older row through the
series "premiered earlier" tolerance — portals report the running season's
year while TMDB reports the premiere — and then let `pickMostPopular` decide
across every admitted candidate, discarding the year evidence that had just
admitted them. The better-known show won on votes, and the newer series
rendered its poster, cast, genres and rating.
Rank admitted candidates by year evidence first (`yearEvidenceTier`: the
provider's exact year, then a year off by one, then the series tolerance) and
let popularity break ties only inside the strongest tier any candidate
reached. The tolerance stays — three of eight real lookups from one install
depend on it — but it is a last resort, not an equal. Measured over 400
Cyrillic series titles sampled from a real catalog, 20 normalized keys had a
same-titled older series and 16 of those were the more popular row.
The mirror case is accepted knowingly: a long-running show whose stated
season year happens to BE another same-titled show's premiere year now
resolves to the newer show. Only the older show's season air dates could
separate the two and a search response does not carry them, while that shape
needs three coincidences at once against one that needs none.
Search cache keys move to `|v4` with a matching startup cleanup, because a
positive row naming the wrong show stays fresh for 30 days.
Merged with `Build on windows x64` red: the checked-in Windows Embedded MPV
runtime pin points at an upstream release whose retention expired, so that
job fails repository-wide on a cold cache. Unrelated to this change; tracked
separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "current programme" under each Xtream Live TV row was written only on
scroll-into-view, on a new EPG result, or on an EPG offset change. Nothing
re-evaluated it as wall-clock time passed, so once a programme ended the row
stayed on it until the category was left and re-entered. The progress bar
under the row never moved either.
The rows on screen now re-check themselves once a minute. A programme still on
air only has its progress bar advanced, at no request cost; once it ends the
row is re-picked, and only a programme on air or upcoming may replace it, so a
guide that has run out can never walk the row backwards. An exhausted cache is
dropped and refetched at most once per cache lifetime per channel, while an
empty answer from the provider is left alone. What is on screen stays there
until a replacement arrives, so a refreshing row never blanks out.
A programme occupies [start, stop) in every comparison, the visible slice is
read from the viewport rather than remembered, and the two services behind
this are root-provided because a live layout mounts the channel list more than
once over a single EPG queue: EpgRefillLimiter is the floor on refetching an
exhausted guide, keyed by playlist since stream ids are provider-local, and
EpgRefreshCoordinator owns the one timer and merges every mounted list's
request, because the queue is latest-wins and separate timers would cancel
each other on exactly the boundaries that matter.
Contract: docs/architecture/m3u-playlist-module.md ("Xtream channel-row
programme refresh").
Fixes#767. Supersedes #1610.
Co-Authored-By: Justin Willhite <5132924+thejdubb02@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Live channels in Favorites and Recently viewed now offer "Open in <playlist>"
for Stalker portals, the counterpart of the VOD "View in portal" action. The
chip in the programme panel and the channel's context menu jump to the channel
inside its portal's Live TV, with its genre selected and the channel playing.
Radio stays hidden: it is a separate legacy-paged section with no
open-on-arrival contract.
The handoff refuses to guess. The store records which genre the rendered ITV
list belongs to, so the deferred play waits for a list that can actually serve
the channel instead of inferring it from array identity. An id claimed by one
channel as its `id` and another as its `stream_id` is ambiguous, so neither is
played and the user still lands in the right genre. The handoff is abandoned
when the user changes genre, search, portal or section first.
Two pre-existing defects surfaced during review and are fixed here:
- A blank provider id shadowed a valid one. `a ?? b` keeps an empty string, so
a channel with a blank `stream_id` was stored with no identity at all and
could not be selected, played or found again. Six writers had that shape and
three private copies of the skip-the-blank rule; all now go through one
`firstNonBlankStalkerId` helper.
- Route-session readiness could publish before the store held the portal's
row. The constructor starts one sync and the first navigation starts another,
and the second skipped the bootstrap because the playlist id was claimed
before the awaits that install it. Arrivals are now serialized, the id is
claimed only after the store write resolves, and only the newest sync
publishes readiness.
Both fixes carry regression tests that fail on the old behavior.
Xtream and Stalker live cards on the dashboard carried no XMLTV key, so the
rails never asked anything for them and showed only the LIVE chip. Their
programme now comes from the portal, one card at a time and only once the card
is on screen.
- `lib-dashboard-rail` reports the cards inside its viewport through an
IntersectionObserver rooted at the track; `DashboardLiveEpgPresenter` unions
them with the pinned hero row and hands the set to
`DashboardPortalLiveEpgPresenter`.
- `DashboardPortalLiveEpgService` runs the bounded queue — two requests in
flight, 200 ms apart, one card each — through
`StreamResolverService.loadEpgForItems`, publishing every answer the moment
it lands, so the page never waits and a slow portal delays no other card. A
card scrolled past before its turn is never requested.
- A programme is trusted for 60 s, an empty answer for 30 s (the resolver
reports a dead portal and a guide-less channel identically), and a
completion captures both the display offset and the EPG source revision,
requeueing itself when either moved.
- The presenter hands its wanted set back on destroy. Desktop only: the shared
collection resolver is gated on the local XMLTV bridge.
- A shimmer placeholder shows only before a card's first portal answer; M3U
cards keep the batched XMLTV lookup.
Every dashboard title carried a "Xtream · Series" / "Stalker · Movie" subtitle. Provider kind and content kind are the app's own taxonomy, not a property of the title, and they are identical on every card in a rail — so the one line that could tell two cards apart said nothing.
The hero now shows the source name alone, through `playlistDisplayLabel` (a stored playlist name is routinely the pasted URL with credentials, or a MAC). Continue Watching cards show what actually varies: the "S1·E5" chip plus "12 min left". Favorites keep the title alone, Recently Added keeps the source name, and a meta row with nothing in it is no longer rendered.
Stalker shows filed under Movies had no badge, no progress and no resume. An embedded-VOD row announces its episodes through a `series[]` array and carries no `is_series` flag, so `extractStalkerItemType` reports `movie` on purpose — the item must keep routing to the VOD catalog — while its progress is a set of episode positions keyed by the parent id, which the dashboard was looking up as a single `vod` row and never finding.
Split the two questions: `PortalActivityItem.watch_kind` records the progress model when it differs from the routing type, and every reader that has to choose goes through `resolvePortalActivityWatchKind` instead of `type`. That makes the resume handoff reachable for Stalker, so wire it through `STALKER_SERIES_RESUME_TARGET`: consumed once after the series positions are read, hydrating a lazy Ministra season first with a bounded two-attempt retry, and playing nothing at all when the position read failed rather than restarting the episode from zero.
Also fixes the global-recent route template, which bound `[seriesResume]` only on its Xtream branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>