Merge branch 'master' of https://github.com/4gray/iptvnator into claude/server-storage-pwa

# Conflicts:
#	docs/architecture/pwa-self-hosted.md
This commit is contained in:
4gray committed 2026-10-13 09:14:50 +02:00
commit de87c1e4d3
78 files changed
+4367 -276

No files matched your search

@@ -157,6 +157,14 @@ Contracts:
- Hosts provide hero chips/meta/actions as `*appDetailTags` / `*appDetailMeta` /
`*appDetailActions` templates; the shell stamps them into the hero and again
into the About block. Degradation stays "missing → not rendered".
- Provider stand-ins count as missing: `N/A` (any case), a lone `-`, and a
`0` IMDb/Kinopoisk score are dropped where provider rows are mapped, by
`readProviderText` / `readProviderRating`
(`libs/shared/interfaces/src/lib/provider-metadata.util.ts`) — in
`createStalkerInfo()`, the `vod-details-adapters.ts` normalizers and the
Xtream store's detail selection. Templates keep plain `@if` guards; do not
teach them the placeholder list. Widen the list only with evidence of a
provider sending the new stand-in.
- The shell never wraps the `[detail-player]` slot in a conditional; the
host's `@if (inlinePlayback())` is the only creator/destroyer of the
player subtree, so shell state changes cannot recreate the `<video>`.
+20 -2
View File
@@ -636,6 +636,13 @@ button disabled state at current-season boundaries.
Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.
`ended` alone does not prove the episode is over: mpv reports a connection the
server closed (and ffmpeg could not reopen) as EOF too, once its cache runs
dry. `EmbeddedMpvPlayerComponent` therefore emits `playbackEnded` only for an
`ended` session that carries no `reconnect`; while the main process reloads a
VOD that ended well before its duration (see Network Auto-Reconnect), the
episode continues instead of autoplay skipping its rest (issue #1902).
## Session Options (Extra libmpv Options)
`Settings > Playback > Extra embedded MPV options` is a free-form textarea,
@@ -701,8 +708,9 @@ included.
`EmbeddedMpvReconnectCoordinator`
(`apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts`) reloads
the last user-requested playback when a session reports a stream loss: an
`error` status, or `ended` while the playback is live (`isLive`, else "no
`contentInfo`", the same rule the renderer uses). It is driven by
`error` status, `ended` while the playback is live (`isLive`, else "no
`contentInfo`", the same rule the renderer uses), or `ended` on VOD well
before the known duration. It is driven by
`refreshSession()` status transitions, so it covers every engine, and it is
captured per session from `Settings.embeddedMpvAutoReconnect` (default on;
mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
@@ -716,6 +724,16 @@ mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
the IPC socket so a stream that ended reports `ended` rather than the
`paused` that keep-open would otherwise look like, and an mpv process that
exits abnormally reports `error`.
- A VOD `ended` counts only when more than `max(10 s, 1 % of the duration)`
remained (`isEmbeddedMpvEarlyEof`): the margin absorbs container rounding
and the whole-second snapshot values, and with no known duration an EOF is
always the end. A reload the engine accepted records where it resumed; an
early EOF before playback gets 5 s past that point (and with no user seek
in between) ended where the file does (a duration mpv estimated from the
bitrate can overshoot) and is left as the end, so autoplay still continues
after one attempt instead of a reload loop. A refused reload records
nothing, so the original EOF keeps its backoff.
With the setting off, an early EOF stays a plain `ended`.
- Backoff is 2 s, 4 s, 8 s, 16 s, 30 s, 30 s, at most six attempts per
outage. The budget resets only after 30 s of uninterrupted `playing`, so a
stream that flaps every few seconds runs out of attempts instead of being
+13
View File
@@ -633,6 +633,19 @@ channel-list-container/
- `PlaylistsService.updatePlaylistMeta()` persists `hiddenGroupTitles` into the
stored playlist payload, and M3U refresh/update flows preserve the existing
value when refreshed playlist data omits the field.
- Electron loads the playlist store from `DB_GET_APP_PLAYLIST_METAS`, which
reads columns only, so `hiddenGroupTitles` is absent from the store after a
restart. `M3uWorkspaceRouteSession` copies it from the full playlist read into
the store (`updatePlaylistMeta` with `persist: false`) before publishing the
channels, unless the store value changed while that read was queued (#1894).
A restored or deep-linked route can finish that read before the startup
inventory, which waits for settings and EPG reconciliation, publishes the
playlist; the hydration then waits for the entity, since `updateOne` drops an
update for a missing one. The startup screen keeps the router outlet hidden
until that inventory is published, and the hydration runs in the same store
update, so the rail never renders a hidden group. A save's meta write joins
the per-playlist queue that `getPlaylist()` reads through, so a reopened
route reads the saved value.
- The groups route keeps the manage action reachable even when every group is
hidden by separating "playlist has no groups" from "no visible/search-matching
groups" empty states.
+75 -14
View File
@@ -704,6 +704,11 @@ source's default section, which is VOD (`getPlaylistLink` links to
and the first page is the "All items" grid. The plan's "live category list"
would need a second click and is not measured; the landed section is
recorded under `evidence.firstPage.section`.
The seeded portal is already imported, so the click only reads the saved
catalog and finishes well inside the shell's show delay for the import
overlay ([workspace shell](workspace-shell.md)): a J2 click must not mount
`app-workspace-shell-import-overlay`, and before that delay it flashed for
about two frames between the skeleton and the first page.
**HTTP requests to the mock.** The J2 profile is seeded with the origin of a
loopback proxy in the test process
@@ -922,11 +927,13 @@ put behind the request ledger: global search reads only the local database.
**What typing does.** The header search box
(`app-workspace-shell-header .search-field input[type="search"]`) applies
its term after `SEARCH_INPUT_DEBOUNCE_MS` (350 ms,
its term after `SEARCH_INPUT_DEBOUNCE_MS` (300 ms,
`WorkspaceShellSearchSyncService`) and writes it to the URL as `q`. On
`/workspace/search` (`app.routes.ts`, Electron only) `SearchResultsComponent`
adopts `q` and runs `executeSearch` after its own 300 ms debounce, which
calls the `dbGlobalSearch` bridge method once with a limit of 101.
runs `executeSearch` as soon as the applied term reaches the store, with no
debounce of its own, which calls the `dbGlobalSearch` bridge method once
with a limit of 101. A newer term supersedes a query still in flight: its
result is dropped by request version.
**Start.** After J1 has ended, the test clicks the rail's **Global search**
link and focuses the header search box (not measured), installs the IPC
@@ -936,7 +943,7 @@ been quiet for 1 s: no DOM mutation, no new or pending bridge call, and an
unchanged `main.sqlStatements` total (30 s timeout, which fails the
iteration). J1's capture is detached. The test then types the query with one
`keyboard.type` call per character on a fixed schedule, 100 ms apart from
the first key (well below the 350 ms debounce, as steady typing would be),
the first key (well below the 300 ms debounce, as steady typing would be),
and samples the main process just before each key. The probe's
capture-phase `keydown` listener on `window` stamps every key in the search
box before the app sees it; the first one starts the journey and sends
@@ -945,7 +952,7 @@ rejects an iteration whose DOM mutations, bridge calls or SQL statements
moved between the quiet snapshot and the first key.
**End.** "No DOM mutation for 200 ms after the last keystroke" alone would
end inside the 650 ms of debounce, before any query ran: nothing in the DOM
end inside the 300 ms of debounce, before any query ran: nothing in the DOM
changes while the term waits. So after the sixth key the probe waits for
the results of the final term to be shown (the path ends with
`/workspace/search`, the URL `q` is the query, no `.loading-state` is
@@ -970,7 +977,7 @@ window closed until its results replace the old ones.
`evidence.finalQuery` keeps its term, result length and duration. The
record also rejects an iteration in which two keydowns were more than
250 ms apart (`typing-cadence`; the gaps are `evidence.keyIntervalsMs`).
At that point a late key on a busy machine could let the 350 ms debounce
At that point a late key on a busy machine could let the 300 ms debounce
apply an intermediate term, which steady typing does not.
A settle that times out fails the iteration with what the probe and the
@@ -1012,9 +1019,10 @@ counter: global search does not touch the network.
| `firstKeystrokeToFirstResultMs.p50/.p90` | First mutation batch with a visible result card (for any term) minus the first keydown. |
| `lastKeystrokeToSettledMs.p50/.p90` | Settled moment (the last mutation batch before the 200 ms quiet window) minus the last keydown. The quiet window itself is not included. |
Both are taken in the renderer. With the current debounces both include
the 650 ms the term waits (350 ms in the shell, then 300 ms in the results
component); the first also includes the 500 ms of typing.
Both are taken in the renderer. Both include the 300 ms the term waits in
the shell; the first also includes the 500 ms of typing. Until the single
debounce below, the wait was 650 ms (350 ms in the shell, then 300 ms in the
results component).
### First measurement
@@ -1040,6 +1048,46 @@ debounces (350 ms in the shell, then 300 ms in the results component). The
query itself, from the bridge call to the rendered first page, takes the
remaining 30-40 ms.
### One debounce
The results component's own 300 ms timer is gone: the shell debounce is
now the only one, and global search runs as soon as the shell applies the
term. `SEARCH_INPUT_DEBOUNCE_MS` went from 350 to 300 ms. It has to stay
above the gap between keys of steady typing, or a word typed in one go
queries once per key; 300 ms covers an average typist (about 200-250 ms
between keys) with some margin, while the query and first page take only
30-40 ms of the remaining time. The route sync in the component reads the
store term untracked, so a term the shell applied before it writes `q` is
no longer pushed back to the previous `q` and queried again.
Local, macOS, 2026-10-11, three J4 runs per build (five measured
iterations each), the same machine, master `633fde3e0` against this
change:
| Measurement | Two debounces | One debounce |
| ------------------------------------ | -------------------- | -------------------- |
| `lastKeystrokeToSettledMs` P50 / P90 | 692-695 / 695-697 ms | 336-337 / 337-339 ms |
| `firstKeystrokeToFirstResultMs` P50 | 1,190 ms | 832-834 ms |
| `renderer.ipcCallsPerSearch` | 1 | 1 |
| `renderer.sqlStatementsPerSearch` | 2 | 2 |
| `renderer.domMutationsToResults` | 402 | 392 |
Per keystroke (query calls / SQL / DOM mutations / CD ticks) at 100 ms per
key, keys 1-5 are unchanged at `0/0/0/1`; key 6 is `1/2/402/9` before and
`1/2/392/7` after.
Slower typing was measured with a scratch change to the journey (key
interval and cadence guard raised, quiet window 1 s; not committed). At
400 ms per key both builds run one query per key from the second
character, five in all with 2 statements each: the shell debounce already
expired between keys before this change, and the results timer only
delayed each query. Before, the queries trail the keys by 650 ms (keys
`y s t e m` read `0 1 1 1 2`); after, each follows its own key. At 325 ms
per key the two builds differ: before, one query in 17 of 20 iterations
(two or four when a gap stretched past 350 ms); after, five. Typing with
gaps between 300 and 350 ms is the only range where this change adds
queries; each is a 2-statement query whose stale result is dropped.
About one launch in sixty showed the empty-results view: the single
`dbGlobalSearch` call went out with the same arguments (`system`, all three
types, hidden categories included, limit 101) and the main process answered
@@ -1064,8 +1112,8 @@ persisted status (`xtream-import-status:<id>:<type>`) reads `completed` and
the type has rows, unless the portal has no categories of that type. Rows
are required because a refresh deletes the catalog without resetting the
status. `xtream-import-wait.e2e.ts` holds the account check past the overlay
window and fails without it. No J4 baseline exists yet; J4 counters join the
ratchet once three runner runs agree.
window and fails without it. Two J4 counters are enforced since #1899
(see [Enforced journey counters](#enforced-journey-counters)).
## `renderer.initialBytes`
@@ -1317,8 +1365,8 @@ largest distance in it, the content pane's 323 px, which is why Back added
buttons appearing when the categories arrive. The skeletons of the category
list and the grid are replaced by content without any shift. Nothing was
hidden behind the startup overlay: the "Syncing playlist" overlay of the
cached catalog load flashes for about two frames after the shift and covers
no further one.
cached catalog load flashed for about two frames after the shift and covered
no further one. It no longer mounts on a J2 click (see the J2 section).
The content frame and the header controls now slide into place instead of
jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout
@@ -1332,6 +1380,14 @@ changes) and the wall-clock entries did not move. Under
`prefers-reduced-motion` there is no slide, so a user with that preference
still sees the jump that the journey no longer measures.
The two J4 entries, `search/renderer.ipcCallsPerSearch` 1 and
`search/renderer.sqlStatementsPerSearch` 2 (#1899, `evidenceRun`
38165392046), were identical in all 816 J4 iterations of the 136 CI journey
jobs since #1816 and in all 18 iterations of three runs of #1899. They guard
the debounce: a search that queried per keystroke, or ran a second query for
the same term, raises them. Like the entries in the table they carry the
guard-only note: #1899 halved the J4 wall clock without moving either.
Being deterministic is not the same as being validated. Principle 3 of the
plan promotes a counter to a guardrail once a PR has shown that lowering it
lowered the journey's wall-clock. None of these counters has that evidence
@@ -1367,7 +1423,12 @@ Not enforced, with the reason:
- J3 `renderer.ipcSerialDepthToPlaying` (4 on the runner, 3 on a Mac):
added after these runs and measured in one runner run so far; a
candidate once `master` runs agree.
- J4: not measured yet.
- J4 `renderer.domMutationsToResults` (402 on `master`, 392 since #1899),
`renderer.ipcSerialDepthToResults` (1) and `renderer.layoutShiftScore`
(0): identical in the three #1899 runs; candidates once `master` runs
agree.
- J4 `renderer.cdTicksToResults` (11 to 13) and `renderer.longTasks`: see
the general reasons above.
Runner counters differ from a Mac (the Linux-only `getWindowState` call, for
one), so take every journey baseline value from the runner only.
+47 -1
View File
@@ -12,6 +12,8 @@ settings screen.
- Manifest types: `libs/shared/interfaces/src/lib/playlist-backup.interface.ts`
- Xtream pending restore storage:
`libs/services/src/lib/xtream-pending-restore.service.ts`
- Browser (PWA) Xtream user state:
`libs/services/src/lib/pwa-xtream-backup-state.service.ts`
## Manifest Contract
@@ -61,9 +63,12 @@ Xtream backups export only connection metadata plus portable user state.
- User state:
- hidden categories by `{ categoryType, xtreamId }`
- favorites by `{ contentType, xtreamId, addedAt?, position? }`
- recently viewed by `{ contentType, xtreamId, viewedAt }`
- recently viewed by `{ contentType, xtreamId, viewedAt, backdropUrl? }`
(`backdropUrl` is written and read by the browser build only)
- playback positions as `PlaybackPositionData[]`
- optional VOD source pins by `{ matchKey, contentId, updatedAt? }`
- optional title snapshots `collectionItems` (browser build only; see
[Browser runtime](#browser-runtime))
An imported Xtream archive can wait for its catalog to download. Export reads
that pending snapshot before consulting the catalog: its hidden categories,
@@ -225,6 +230,47 @@ categories/favorites/history can
remain after a later failure, but the snapshot remains available to retry the
complete restore. It is consumed only after every restore step succeeds.
### Browser Runtime
Whenever `XTREAM_DATA_SOURCE` resolves to `PwaXtreamDataSource` — the
browser, or a desktop bridge without the Xtream SQLite API
(`RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`) — Xtream
favorites, recently viewed items, playback positions and title snapshots live
in the `USER_DATA_STORAGE` maps owned by `PwaXtreamDataSource`
(`XTREAM_USER_DATA_KEYS`; see
[PWA portal user data](./pwa-self-hosted.md#pwa-portal-user-data)).
`PwaXtreamBackupStateService` reads and writes them through that token, so the
self-hosted server storage is covered too.
- Export converts each playlist's entries to the archive shape. Typed keys
(`movie:42`) carry their identity; a legacy bare id resolves only through
the snapshot stored under it, and is left out when none identifies it.
Recently viewed keeps the newest view per title, with the artwork saved
for that view as `backdropUrl`. `collectionItems` carries
the snapshots of exported favorites and history, and is omitted when there
are none. Hidden categories stay `[]` and `sourcePins` stays absent: the
browser tracks neither.
- An unreadable map (storage error, malformed JSON, a playlist entry that is
not a list) fails the export instead of being written as an empty list.
- A pending restore snapshot still wins over the stored maps, as on desktop.
- Import parks the restore state like the desktop does, then applies it at
once through `XtreamPendingRestoreService.applyAndConsume`: the browser has
no catalog cache to wait for. Favorites, history and positions of the
imported playlist id are replaced (positions get that id); other playlists'
entries are untouched. `collectionItems` merge into the playlist's
snapshots, keyed by typed identity; an archive without them leaves the
snapshots alone, malformed items are skipped, a damaged cache is replaced,
and a quota error on this cache does not fail the restore.
- The lists are written before the optional title snapshots, so the cache
never takes storage space they need (as for additions; see the
[snapshot-storage contract](./xtream-portal-compatibility.md)). A failed
write fails the entry and keeps the snapshot parked, so the Xtream store
replays the complete state when the playlist next opens. That replay
carries no title snapshots, so they are still merged after a failed list
write, unless storage is full and they would take room the replay needs.
Older builds ignore `collectionItems`; the manifest version stays `1`.
## Current UX
The settings page now exports/imports “playlist backups” instead of the old
+5 -1
View File
@@ -437,7 +437,11 @@ and sidecar state in localStorage:
`PwaXtreamDataSource` and `PwaCollectionStorage` reach these keys through the
`USER_DATA_STORAGE` token from `@iptvnator/services` (default:
`localStorage`), never through `localStorage` directly, so server storage
mode can substitute `ServerUserDataStorage`.
mode can substitute `ServerUserDataStorage`. The key names are shared as
`XTREAM_USER_DATA_KEYS` with the playlist backup, which exports and restores
the favorites, recent items, positions and snapshots through the same token
(`PwaXtreamBackupStateService`; see
[backup/restore](./playlist-backup-restore.md#browser-runtime)).
Entries should include a content snapshot when the item is added. Global
collection routes and the dashboard can then restore titles, posters, content
+11
View File
@@ -618,6 +618,17 @@ Xtream-only row shape:
12. `DB_GET_APP_STATE`
13. `DB_SET_APP_STATE`
`DB_UPSERT_APP_PLAYLIST(S)` write whole documents: the row built by the shared
codec (`buildPlaylistRow` in `libs/shared/database`) replaces the stored one.
`playlistConflictUpdate` sets every optional column the document leaves empty
(an empty string or a missing key) to NULL instead of skipping it, because
`parseAppPlaylist` prefers a column over the payload and would otherwise bring
a cleared User-Agent, Referrer, Origin, credential, EPG list or file path back.
Only `date_created` is kept from the first insert. Callers must send the full
document (`PlaylistsService` reads, patches and writes the whole row); a
column-only change goes through `DB_UPDATE_PLAYLIST`. The self-hosted web
backend's playlist repository uses the same conflict update.
`DB_GET_APP_PLAYLIST_METAS` is the preferred path for summary surfaces such as
the workspace sidebar and dashboard source rail. It selects playlist metadata
columns only and deliberately skips the large `payload` column, which can
+11
View File
@@ -422,6 +422,17 @@ Failure-handling rule:
not `undefined` collections or renderer exceptions. The workspace Stalker
context panel and live layout rely on this guarantee.
Detail metadata rule:
- `createStalkerInfo()` (`stalker-vod.utils.ts`) is the mapping both detail
templates read: the series view binds `info` directly and the shared VOD
view reads it through `normalizeStalkerVod()`. Each field takes the first
usable candidate, and portal stand-ins (`N/A`, `-`, an unrated `0` score)
become `''` there — see the placeholder rule in
[Embedded Inline Playback](./embedded-inline-playback.md). It runs before
TMDB enrichment, which keeps a non-empty provider rating and release date,
so a surviving stand-in would also block the TMDB value.
## Stalker Identity Policy
Full Stalker/Ministra portal authentication defaults to MAC-only identity. The
+7 -1
View File
@@ -200,7 +200,13 @@ services:
5. `WorkspaceShellCommandPaletteService` owns command-palette dialog lifecycle
and recent-command recording.
6. `WorkspaceShellXtreamImportService` owns Xtream import/refresh overlay
state and labels.
state and labels. Remote import phases and refresh preparation of the
shown playlist (of any playlist on the dashboard) show the overlay at
once. A session that has only read the saved catalog (`loading-cached`,
`isImporting` still false) shows it only after
`XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS` (300 ms), so a warm open goes from
the skeleton to the page without flashing the overlay, while a slow or
stuck cache read still blocks and stays cancellable.
When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
+12
View File
@@ -405,6 +405,7 @@ sometimes only respond to that misspelled action.
| `tzoffset:tzoffset` | 6006 | live:2, vod:1, series:1 | 3 | active |
| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active |
| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active |
| `streamdrop:streamdrop` | 8181 | series:1 | 1 | active |
| `expired:expired` | 4004 | 4 each | 10 | Expired |
| `inactive:inactive` | 5005 | 4 each | 10 | Disabled |
| `<any other>` | hash | 6 each | 30 | active |
@@ -428,6 +429,17 @@ Timeshift URLs of `epg` and `epglong` play the local MPEG-TS clip
the catch-up seek bar's label bounds; `epg:epg` keeps its short titles,
which other tests assert verbatim.
### `streamdrop:streamdrop` fixture details
Its three episodes play the local 60 s Matroska clip
(`src/fixtures/episode.mkv`, generation command in the fixtures README) with
byte ranges, but `dropping-episode.ts` cuts the first response that reaches
35 % of the file and answers 403 for the next 4 s, so ffmpeg's own HTTP
reconnect fails and mpv reports EOF mid-episode. `POST /reset` re-arms the
drop. The Electron E2E `embedded-mpv-series-stream-drop.e2e.ts` uses it to
check that Embedded MPV resumes the episode instead of autoplaying the next
one (issue #1902).
### `marketing:marketing` fixture details
This scenario is reserved for release screenshots and marketing materials:
@@ -344,6 +344,12 @@ resolves. An unresolved source exposes no actions. Playback and download
descriptors also fall back to the catalog title and poster after `info` and
`movie_data`.
Provider stand-ins in `get_vod_info` / `get_series_info` (`N/A`, `-`, an
unrated `0` score) are emptied before the response becomes the store
selection (`stores/xtream-detail-metadata.ts`), since both detail routes bind
`info` fields directly. A `get_vod_info` response made only of stand-ins
therefore counts as sparse and gets the fallback page above.
## Catch-Up Playback URLs
Xtream-compatible portals differ on archive playback URL shape. IPTVnator