mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/channel-history-playback-check-0b3bd9
# Conflicts: # docs/architecture/workspace-dashboard.md
This commit is contained in:
commit
9a2f1fb7e8
85 files changed
+5216
-1215
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user