Merge remote-tracking branch 'origin/master' into claude/channel-history-playback-check-0b3bd9

# Conflicts:
#	docs/architecture/workspace-dashboard.md
This commit is contained in:
4gray committed 2026-09-27 21:31:45 +02:00
commit 9a2f1fb7e8
85 files changed
+5216 -1215

No files matched your search

+60 -7
View File
@@ -63,7 +63,8 @@ longer in the DOM, and a source card has a non-empty client rect. Counters are
frozen at that microtask checkpoint, so bridge calls and mutations issued
later in the same task are included and everything after it is not.
Three test-side pieces are injected; production code is not changed:
Three test-side pieces are injected. The app itself only contributes the
main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
- `journey-renderer-gate.cjs` is loaded into the main process with `-r`, the
mechanism Playwright uses for its own loader. Playwright resolves
@@ -72,7 +73,15 @@ Three test-side pieces are injected; production code is not changed:
registered afterwards would race the first document. The gate makes the
first `loadFile` navigate to `about:blank` and holds the real load until
the test releases it. A 15 s safety timeout releases it on its own and the
iteration is then invalid.
iteration is then invalid. Electron emits `ready-to-show` for the first
paint of a hidden window, and `about:blank` paints too, so the gate drops
that event while the window shows `about:blank`; otherwise the app would
show a blank window and freeze its `ready-to-show` counter before its own
document exists. Electron emits the event again for the real document's
first paint because the window is still hidden, which is the moment
production sees. The gate also keeps the listener the app registers with
`ipcMain.handle('performance:read-counters')`, so the test can call it from
the main process.
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
`about:blank` page, so it runs at the start of the real document. It
records that it ran while the document was still `loading` with zero
@@ -92,12 +101,60 @@ Three test-side pieces are injected; production code is not changed:
| `renderer.layoutShiftScore` | Sum of `layout-shift` entries with `hadRecentInput === false`, rounded to three decimals (a shift of 0.0001 flips in and out of the cutoff between runs; the CLS "good" threshold is 0.1, so three decimals keep the counter exact without hiding anything a user could see). The cutoff is sampled in a timer queued from the first `requestAnimationFrame` after the terminal batch, that is after the frame that paints the card has been committed; entries delivered live after the terminal batch are buffered and filtered by the same cutoff. |
| `renderer.longTasks` | `longtask` entries over 50 ms up to that same cutoff, which includes the task that rendered the card. The count depends on machine speed, so it is evidence until a run shows it is stable on the CI runner. |
#### Main-process counters
With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets,
`apps/electron-backend/src/app/services/debug-trace.ts` keeps named counters
in the main process (`services/performance-counters.ts`) and `main.ts`
registers the `performance:read-counters` IPC handler. Without the flag
nothing is counted, no listener is attached and the handler does not exist;
the preload never exposes the channel. SQL statements are counted only with
`IPTVNATOR_PERF_COUNT_SQL=1` as well, because the hook wraps every statement
execution: the journey sets both, while the M3U, refresh and Xtream
benchmarks run with the capture flag alone and keep measuring unwrapped
statements. A harness test fails if any other source sets the SQL flag. After the renderer probe completes,
`journey-main-counters.ts` calls the handler through `electronApp.evaluate`
and the gate's tap.
| Counter | Source |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `main.modulesRegisteredBeforeWindow` | `main.startupPhases`, one per `traceStartupPhase` call (the phases printed as `[startup]` trace lines), frozen right after the first main window is constructed. |
| `main.sqlStatementsBeforeReadyToShow` | `main.sqlStatements`, frozen at the first main window's `ready-to-show`. It counts the statements of the main-thread connection (`sql-main`, schema creation and migrations) and of the database worker, which posts its count over its message port ([DB worker](sqlite-db-worker.md)). |
Main-thread statements are counted synchronously. The worker flushes its
count before every other message it posts, so every worker statement whose
response the main process has handled is included. The worker count is
ordered against the worker's responses, not against wall-clock: statements
whose count is still in flight when `ready-to-show` is dispatched are not.
One call of `run`, `get`, `all`, `iterate` or `exec` that returns normally is
one statement; on the launch workloads this matches the number of SQL trace
lines exactly. An `exec` with several statements would count as one, so the
shared connection passes one statement per call, and its historical-upgrade
test fails on a batch.
`main.sqlStatementsBeforeReadyToShow` is not yet deterministic. The main
thread runs the shared connection's schema creation and migrations (about 90
statements on the J1 profile) in one synchronous block after the load event,
and `ready-to-show` is dispatched after it. The stale-download and
stale-recording recovery that follows (one statement each) races the event,
so iterations differ by two and the summary marks the counter
`stable: false`. The database worker runs no statement before the first
paint.
Each frozen counter carries its epoch. The record refuses an iteration whose
window counter was frozen after the gate saw the first load, or whose
`ready-to-show` counter was frozen before the gate released the real
document. Running totals at read time are kept under
`evidence.mainCountersAtRead`, the freeze epochs under
`evidence.epochs.mainWindowCreated` and `evidence.epochs.mainReadyToShow`, and
the number of dropped blank `ready-to-show` events under
`evidence.rendererGateReadyToShowHeldOnBlank`.
Counters are exact: the summary carries the value shared by every measured
iteration. When iterations disagree, the summary reports the maximum and marks
the counter `stable: false` under `counterStability`; such a counter is not
promoted to a guardrail until it is deterministic.
Two counters from the plan are listed under `unavailable` with the reason
One counter from the plan is listed under `unavailable` with the reason
instead of being faked:
- `renderer.cdTicksToFirstCard`: the `electron-performance` build optimizes
@@ -105,10 +162,6 @@ instead of being faked:
`window.ng` and `ɵsetProfiler` is unavailable. The probe checks this at the
terminal moment and the record refuses a build where the hook exists but was
not counted.
- `main.sqlStatementsBeforeReadyToShow`: SQL statements are only visible as
worker-thread trace lines on stdout, which Node forwards asynchronously, so
they cannot be ordered against `ready-to-show`. Plan item A2 adds a channel
that can be counted.
### Wall-clock
+23
View File
@@ -444,6 +444,29 @@ first, this receipt remains distinct from the later authoritative
exposing the pending request. Disabled profiling performs no receipt clock or
transport work, and `DatabaseWorkerClient.cancel()` remains fire-and-return.
With `IPTVNATOR_PERF_CAPTURE=1` and `IPTVNATOR_PERF_COUNT_SQL=1`, the worker
connection also counts the SQL statements it executes and posts `performance-sql-statements` messages that
carry only a positive count, never SQL text or bound values. Counts are
coalesced per microtask and flushed before every other worker message, so a
response never overtakes the statements that produced it.
`DatabaseWorkerClient` adds them to the main-process `main.sqlStatements`
counter and settles nothing. Statements are counted by wrapping the
execution methods of better-sqlite3's `Statement` prototype and the
connection's `exec`, not through the `verbose` callback: a callback makes
better-sqlite3 expand every statement's SQL, which made bulk inserts two to
four times slower. Counting still adds a JavaScript call per row of a bulk
insert, so it needs `IPTVNATOR_PERF_COUNT_SQL` on top of the capture flag:
only the launch journey sets it, and the import benchmarks that run with the
capture flag measure unwrapped statements. A call that throws is not counted, which matches the SQL
trace for statements that fail before execution. One `exec` call counts as
one statement, so initialization passes one statement per call; the
historical-upgrade test enforces it. The main process counts its
own shared connection the same way (`services/main-sql-statement-count.ts`,
through the shared library's connection observer). Without the flag both
connections are opened unchanged. See
`workers/database-worker-sql-statement-count.ts` and
[performance journeys](performance-journeys.md).
## Renderer Contract
The preload bridge keeps the existing database methods but adds scoped worker
@@ -733,15 +733,15 @@ since shipped.)
does not latch and retries instead. Same gating as trending: TMDB
opt-in + Electron DB worker, deferred behind the dashboard's own data.
- **Hero extras**: `DashboardHeroTmdbService`
(`libs/workspace/dashboard/feature`) patches the hero card with a TMDB
backdrop (when the activity row has none), a rating badge and up to two
genre chips — resolved through the enrichment facade, so items already
opened in a detail view come from the SQLite cache without network.
Results are memoized per lookup identity for the session. The hero renders
immediately from provider data; extras appear when resolved. Series
heroes additionally show the tracked "S{n}·E{n}" badge from the playback
position (no TMDB involved); the watch-progress bar is limited to
movie/series heroes.
(`libs/workspace/dashboard/feature`) patches each movie/series hero slide
with a TMDB backdrop (when the activity row has none), a rating badge, up
to two genre chips, the overview and the release/first-air year — resolved
through the enrichment facade, so items already opened in a detail view
come from the SQLite cache without network. Results are memoized per
lookup identity for the session. Slides render immediately from provider
data; extras appear when resolved and disappear when TMDB is turned off.
Series slides additionally show the tracked "S{n}·E{n}" badge from the
playback position (no TMDB involved). Live slides never query TMDB.
The query is built to **match what the detail view searched with**, not
just what the card displays. A title alone is weaker identity than the
+7
View File
@@ -181,6 +181,13 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the
Use atomized E2E targets when available, for example
`pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`.
Inside `expect.poll`, read a changing list in one DOM snapshot
(`allTextContents()` or `evaluateAll()`, as in
`apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts`), not by
looping over `count()` with per-row `nth(index)` reads. Those reads auto-wait,
so a row removed mid-loop hangs the predicate until the poll times out instead
of letting it retry.
Playwright coverage is measured semantically by tags and critical journeys, not
by a source-line percentage. E2E reports should use tags such as `@critical`,
`@electron`, `@web`, `@xtream`, `@stalker`, `@m3u`, `@search`, `@epg`,
+56 -12
View File
@@ -37,7 +37,7 @@ Core implementation:
```
┌─────────────────────────────────────────────────────────────────────┐
│ Hero — Continue Watching (most recent item) │
│ Hero — rotating cinematic banner (resume · live · discovery) │
├─────────────────────────────────────────────────────────────────────┤
│ Continue Watching · See all → │
│ [poster][poster][poster][poster] →→ │
@@ -73,15 +73,17 @@ Render rules:
slow rail does not hide already available content.
2. `hasPlaylists() === false` → render `<app-empty-state [type]="'welcome-dashboard'">`
full-bleed. All rails and the hero are skipped.
3. `hero()` = `globalRecentItems()[0]`. If present, render the hero panel.
An item enters recent history only after its stream has really played
(see "Recently Viewed Confirmation" in `embedded-inline-playback.md`), so
a channel that failed at once never becomes the hero.
3. The hero (`lib-dashboard-hero`) renders when it has at least one slide;
see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while
the first history load runs. An item enters recent history only after
its stream has really played (see "Recently Viewed Confirmation" in
`embedded-inline-playback.md`), so a channel that failed at once never
becomes a hero slide.
4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden
— there is no "empty widget" placeholder.
5. The continue-watching hero prefers a stored Xtream `backdrop_url`; when it
is missing the UI falls back to a blurred poster treatment instead of
showing a flat panel.
5. Hero slides prefer a stored `backdrop_url`, then the TMDB backdrop; when
both are missing the poster becomes a blurred wash plus key art on the
right instead of a flat panel.
6. Live favorites are promoted into their own live rail; movie/series
favorites render in a separate `Favorite movies & series` rail
(`favoriteMoviesAndSeriesCards`, `data-test-id="dashboard-favorite-vod-rail"`,
@@ -92,6 +94,47 @@ Render rules:
favorites. This avoids first-paint partial counts such as a single Stalker
favorite appearing before M3U favorites finish resolving.
## Cinematic Hero
`DashboardHeroComponent` renders a full-bleed banner: it cancels the page's
`--dashboard-gutter`/top padding and the centred `--dashboard-max-width`
(the page host is the `dashboard` inline-size container), keeps
`clamp(320px, 42vh, 520px)` so the first rail starts above the fold, and uses
`--app-content-bg` as its scrim so it dissolves into the page in both themes.
Slides (`pickDashboardHeroSources`, at most four, stable order, each title
once):
1. the newest unfinished movie/series (`isPortalPlaybackWatched` rows skip);
2. a live channel with a programme on air — the first of
`selectDashboardHeroLiveCandidates` (up to three favourites, then two
recently watched channels) whose EPG answer has a title;
3. one favourite movie/series and one Xtream recently-added title;
4. remaining places round-robin over the next items of those lists;
5. only when nothing qualifies, the newest history row of any kind (a
detail action: it can be a finished title).
While live candidates exist but none has answered yet, one place stays
reserved for the live slide, so its late arrival never evicts a slide the
user may be viewing.
The live candidates are derived and pinned by `DashboardLiveEpgPresenter`
itself (XMLTV lookup and portal queue), independent of the live rails, so the
slide works with those rails hidden. Actions: a resume slide keeps the resume
handoff and adds a detail-only "Details" when a series episode can resume;
discovery slides open the detail page; live slides open the channel. TMDB
extras (backdrop, rating, genres, overview, year) come from
`DashboardHeroTmdbService` per featured title and vanish when TMDB is off.
Rotation is the active dot's CSS fill animation (8 s); its `animationend`
advances. Hover, focus inside the hero and the pause button pause it; an
explicit Play clears the hover/focus pause until they re-arm; under
`prefers-reduced-motion` nothing auto-advances. The active slide is tracked
by id, so a late live slide never moves the user off the current one. Test
hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
`dashboard-hero-dot`, `dashboard-hero-pause`,
`dashboard-hero-primary-action`, `dashboard-hero-secondary-action`.
## Rail Contract
`DashboardRailComponent` is purely presentational:
@@ -112,7 +155,8 @@ Render rules:
1. `WorkspaceDashboardRailsComponent` injects `DashboardDataService`.
2. It derives the dashboard surface via `computed()`:
1. `hero` — first item of `globalRecentItems()`.
1. The hero slides — built by `DashboardHeroSlidesPresenter`, see
[Cinematic Hero](#cinematic-hero).
2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series
cover cards. Portal playback positions are bulk-loaded per playlist so
hero and cards can show progress, remaining time, and series season/
@@ -164,12 +208,12 @@ Render rules:
card counts as visible. Cards that leave the list are reported gone
at once.
- `DashboardPortalLiveEpgPresenter` (component-provided) unions the
visible keys of both rails with the pinned hero key and calls
visible keys of both rails with the pinned hero keys and calls
`DashboardPortalLiveEpgService.sync()` with exactly those entries —
on every change, on the 30 s tick, and on a display-offset change.
It is reached through `DashboardLiveEpgPresenter`, which derives the
portal rows itself from the enabled rails and pins the hero, so the
page component only forwards what a rail can see. The queue lives in
portal rows itself from the enabled rails and pins the hero's live
candidates, so the page component only forwards what a rail can see. The queue lives in
the root service, so leaving the dashboard hands the wanted set back
(`sync([])` on destroy); otherwise the queue would keep asking for
cards on a page that is gone.
+2 -1
View File
@@ -30,7 +30,8 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset. It also keeps count-only main-process counters (startup phases, database worker SQL statements, and their values at main-window creation and `ready-to-show`) and registers the main-only `performance:read-counters` IPC handler, which the preload does not expose; see [performance journeys](../architecture/performance-journeys.md)
- `IPTVNATOR_PERF_COUNT_SQL=1`, together with `IPTVNATOR_PERF_CAPTURE=1`, also counts every SQL statement of the main-process and database-worker connections for `main.sqlStatementsBeforeReadyToShow`; it wraps each statement execution, including every row of a bulk insert, so only the launch journey sets it and the import benchmarks leave it unset
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
- `IPTVNATOR_DISABLE_COMPILE_CACHE=1` disables the main-process V8 compile cache; `IPTVNATOR_COMPILE_CACHE_DIR=<dir>` relocates it. The startup trace reports the outcome as `compile-cache`