mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* docs(agents): compact root guidance and preserve task-specific knowledge * fix(agents): parse guidance navigation with Markdown tokens * fix(agents): validate generic literal repository paths * fix(agents): distinguish code symbols and shortcut images * fix(agents): recognize SCSS filename literals * fix(agents): handle fenced imports and encoded paths * fix(agents): parse prose and rendered HTML anchors * fix(agents): validate rendered HTML navigation * fix(agents): use GitHub-compatible heading slugs * fix(agents): require standalone top-level Claude import * fix(agents): exclude HTML-contained guidance imports * fix(agents): handle image fragments and quoted imports * fix(agents): validate visible HTML and image source sets * fix(agents): recognize package scopes and route source work * fix(agents): parse JSONC and constrain package exemptions * fix(agents): decode link entities and allow package subpaths * fix(agents): route source work and check extensionless files * fix(agents): support package versions and source fragments * fix(agents): accept qualified package prose * fix(agents): retain rendered context for Markdown references * fix(agents): validate visible headings and spaced paths * fix(agents): validate media and hyphenated literal paths * fix(agents): decode full HTML entities and media assets * fix(agents): recognize possessive package mentions * fix(agents): validate extensionless imports and version comparators * fix(agents): retain visible backticks and explicit path punctuation * fix(agents): validate image-map navigation targets * fix(agents): count all Markdown line endings in budgets * fix(agents): delimit package prose at Unicode punctuation * fix(agents): normalize punctuation for extensionless imports * fix(agents): preserve filenames across prose punctuation * fix(agents): validate iframe document references * fix(agents): inspect document suffix before URL fragments * fix(agents): unify Markdown suffix and encoded import guards * fix(agents): handle wildcard versions and alternate documents * fix(agents): validate document formats and trim HTML URLs * fix(agents): cover document families and guidance basenames * fix(agents): require files for media references * fix(agents): preserve block boundaries and validate embeds * fix(agents): normalize internal HTML URL whitespace * fix(agents): reject empty media and ignore URL at-signs * fix(agents): validate srcdoc references and empty srcset * fix(agents): honor HTML bases and preserve adjacent imports * fix(agents): convert base file URLs to native paths * fix(agents): preserve imports after bare URL punctuation * fix(agents): exclude opaque URI prose from import scans * fix(agents): keep import tokens outside URI scheme matches * fix(agents): restrict opaque URI exemptions to parsed links * fix(agents): handle opening prose delimiters * fix(agents): scan nested imports and share document suffixes * fix(agents): reject pathless media and direct file URLs * fix(agents): reject file bases and preserve quoted URL boundaries * fix(agents): distinguish URL quotes and cover guidance variants * fix(agents): validate SVG images and conventional guides * fix(agents): handle declared package names handles and SVG use * fix(agents): normalize closing punctuation on federated handles * fix(agents): normalize Unicode punctuation on handles * fix(agents): normalize possessive federated handles * fix(agents): separate parenthetical prose from handles * fix(agents): exclude www autolinks from import scanning * ci: allow manual CodeQL validation of PR branches * fix(agents): reject nonportable Windows drive links
1950 lines
120 KiB
Markdown
1950 lines
120 KiB
Markdown
# M3U Playlist Module Architecture
|
||
|
||
This document describes the M3U playlist module architecture, which handles traditional M3U/M3U8 playlists (as opposed to Xtream Codes or Stalker Portal).
|
||
|
||
## Overview
|
||
|
||
The M3U playlist module provides:
|
||
|
||
- Channel list display with virtual scrolling (90,000+ channels support)
|
||
- EPG (Electronic Program Guide) integration
|
||
- Favorites management with drag-and-drop reordering
|
||
- Channel grouping, search, and per-list channel sorting
|
||
- Per-playlist group visibility management in the groups view
|
||
- Video playback with multiple player backends
|
||
|
||
## Desktop upgrades from legacy profiles
|
||
|
||
v0.19.0 stores the complete source list in IndexedDB database `iptvnator`,
|
||
version 1, store `playlists`, key path `_id`. Source type is inferred from
|
||
`serverUrl` (Xtream), `macAddress` (Stalker), or the M3U fields; the last-used
|
||
source does not select which records are migrated. Settings live separately
|
||
in `ngStorage`, and player preferences also use localStorage. SQLite already
|
||
holds the Xtream catalogs opened in v0.19, including favorites and history;
|
||
it is not the authoritative inventory of configured sources.
|
||
|
||
The published v0.19 Linux DEB's `app.asar/package.json` names the app
|
||
`electron-backend`. Later packages set `productName: IPTVnator`. Electron
|
||
resolves different `userData` directories from those identities; a later
|
||
`app.setName('iptvnator')` does not reset the cached path. The shared
|
||
`~/.iptvnator/databases/iptvnator.db` is independent of that path, so cached
|
||
Xtream sources can remain visible while the complete IndexedDB list is in
|
||
the older profile. Linux usually places these profiles below `~/.config/`.
|
||
Snap/Flatpak confinement and a changed installation type can give them
|
||
different roots; recovery does not scan unrelated profiles or sandbox roots.
|
||
|
||
`electron-profile-bootstrap.ts` runs before eager main-process imports such
|
||
as `electron-conf`. If the current profile has no IndexedDB, Local Storage,
|
||
Preferences, or `config.json` yet, it reuses the known `electron-backend` profile containing
|
||
`IndexedDB/file__0.indexeddb.leveldb`, preserving its settings as well. It never
|
||
switches an already-used current profile. E2E overrides keep both profiles and
|
||
SQLite under the isolated test root.
|
||
|
||
`PlaylistsService` sends the complete current-profile IndexedDB list through
|
||
`DB_MIGRATE_APP_PLAYLISTS`. The worker validates every ID (including duplicates)
|
||
and commits all rows plus `m3u-playlists-indexeddb-to-sqlite-v1` in one synchronous
|
||
transaction. Any malformed source or failed write rolls back the entire batch;
|
||
restarting retries it. Original IndexedDB records remain in place. Full SQLite
|
||
rows with a `payload` are authoritative and are skipped. Cache-only Xtream rows
|
||
receive the IndexedDB source configuration (cached credentials can be stale),
|
||
retaining creation/import timestamps and all linked catalogs, favorites,
|
||
history and playback positions. No provider connection is needed. Migration
|
||
preserves stored data; it does not create missing provider catalogs or translate
|
||
unrelated historical formats into new features.
|
||
|
||
For an already-used current profile, a separate native recovery offer defaults
|
||
to **Keep current sources**. **Recover all missing sources** explicitly explains
|
||
that it may also restore sources intentionally deleted after upgrading. There
|
||
is no pre-existing deletion ledger that can distinguish these from omissions,
|
||
so recovery is never automatic. Close the old version before accepting.
|
||
Recovery opens a disposable copy of the old IndexedDB, never the original,
|
||
and keeps current settings and full source records. Its separate atomic receipt
|
||
`playlists-electron-backend-profile-v1` prevents any subsequent replay, including
|
||
after source deletion. Declining records `declined`; starting the app with
|
||
`--recover-legacy-playlists` offers it again. That switch does not bypass consent
|
||
or replay a completed recovery. A recovery read/write error leaves current
|
||
sources usable, reports the failure, writes no successful receipt, and can be
|
||
retried on restart.
|
||
|
||
If the old profile is absent, unreadable, in another sandbox, or was already
|
||
cleared by an earlier successful migration, missing source data cannot be
|
||
reconstructed from the current SQLite cache. Restore a backup or recreate those
|
||
sources; do not clear migration flags to force a blind replay.
|
||
|
||
SQL initialization also creates indexes on added columns only after the column
|
||
migrations. In particular, `idx_content_epg_channel` must follow the addition of
|
||
`content.epg_channel_id`; creating it in the initial CREATE TABLE pass aborts
|
||
initialization on the v0.19 schema before the playlist migration can run.
|
||
|
||
Initial workspace routing waits for settings and XMLTV source reconciliation.
|
||
Removing a large obsolete EPG cache can therefore delay the first view even
|
||
after legacy recovery is declined. `AppStartupStatusComponent` shows preparation
|
||
until both the initial routed component and source inventory are ready; routed
|
||
content stays hidden from pointer and keyboard interaction during that interval.
|
||
There is no estimated percentage or timeout that bypasses reconciliation.
|
||
|
||
Source inventory reads recreate the storage request for one automatic retry
|
||
after 300 ms. Exhausted retries emit `Load Playlists Failure` without raw error
|
||
details and leave `allPlaylistsLoaded` false. The startup error offers Retry,
|
||
which dispatches a fresh load without restarting or changing saved sources.
|
||
Before publishing a successful inventory, the effect waits for settings loading
|
||
to register initial cleanup, then waits for any active EPG reconciliation and retries the last failed synchronization using its committed,
|
||
normalized global URLs. A settings-read failure never supplies defaults to this
|
||
retry; a newer successful synchronization clears the failure instead of replaying
|
||
an older source set. Repeated cleanup failure leaves Retry available.
|
||
Every inventory load clears readiness, including reloads after backup import,
|
||
so a later read failure also exposes Retry. A successfully loaded empty inventory
|
||
is distinct from a failed read. The preparation/error surface remains a native
|
||
window drag region, with Retry excluded so it stays clickable. Its static,
|
||
monochrome SVG watermark inherits the theme heading color, fades toward the
|
||
content, and is clipped, pointer-transparent and hidden from assistive technology.
|
||
|
||
Validation: `electron-backend-e2e:e2e-ci--src/legacy-playlist-migration.e2e.ts`
|
||
seeds the exact v0.19 IndexedDB schema and verbatim SQL CREATE statements with
|
||
61 Stalker sources, three Xtream sources, M3U, and linked cached user data.
|
||
It exercises source-list UI, alternate last-used sources, retained settings,
|
||
recovery consent, existing data, write failure, restart, and deletion after
|
||
migration. The refusal case holds a mocked recovery dialog pending until startup
|
||
reaches it, then checks that Sources renders the current source in the same
|
||
launch. Native operating-system dialogs still require separate manual verification.
|
||
The same suite holds EPG reconciliation pending and injects failed metadata reads
|
||
to check preparation, error feedback and recovery through Retry, including a
|
||
second failure after importing a backup into an already loaded session. These use
|
||
controlled IPC faults rather than adding a large, timing-dependent database to CI.
|
||
Local macOS Electron verification does not substitute for installed
|
||
Linux Mint MATE, Snap, or Flatpak upgrade testing.
|
||
|
||
## Module Structure
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ VIDEO PLAYER PAGE │
|
||
│ libs/playlist/m3u/feature-player/src/lib/video-player/ │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ ┌─────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │
|
||
│ │ Sidebar │ │ Video Player │ │ EPG Timeline │ │
|
||
│ │ │ │ (ArtPlayer/Video.js)│ │ (panel below) │ │
|
||
│ │ ┌─────────┐ │ │ │ │ │ │
|
||
│ │ │Channel │ │ │ │ │ │ │
|
||
│ │ │List │ │ │ │ │ │ │
|
||
│ │ │Container│ │ │ │ │ │ │
|
||
│ │ └─────────┘ │ │ │ │ │ │
|
||
│ └─────────────┘ └──────────────────────┘ └────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ NgRx STORE (m3u-state) │
|
||
│ libs/m3u-state/ │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │ Playlist │ │ Channel │ │ EPG │ │Favorites │ │ Filter │ │
|
||
│ │ Reducer │ │ Reducer │ │ Reducer │ │ Reducer │ │ Reducer │ │
|
||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
## M3U Parsing (`iptv-playlist-parser` fork)
|
||
|
||
All four parse call sites (Electron `playlist-source.ts` import, `playlist-refresh.worker.ts`, `web-backend` `/parse`, PWA `playlists.service.ts`) use the
|
||
[4gray/iptv-playlist-parser](https://github.com/4gray/iptv-playlist-parser) fork, pinned by commit SHA in `package.json`. The fork tracks upstream
|
||
`freearhey/iptv-playlist-parser` (currently synced to v0.15.2) plus three deliberate deltas iptvnator depends on:
|
||
|
||
- **`radio` attribute** — `item.radio` (string, `'true'` triggers the radio player, EPG suppression, and external-player gating app-wide). Upstream does not have this field; it must survive every upstream sync.
|
||
- **Pipe stripping** — `item.url` is cut at the first `|`; `|User-Agent=` / `|Referer=` params still land in `item.http`. Upstream 0.15.0 stopped stripping, but iptvnator consumes `item.url` verbatim in hls.js/mpv/vlc, catch-up URL building, and url-keyed favorites.
|
||
- **`#KODIPROP` lines before `#EXTINF` are preserved** (since `v0.15.2-iptvnator.2`) — Kodi property lines apply to the _next_ list entry, so ones placed above the `#EXTINF` are buffered and attached to that item's `raw` in file order (case-insensitive prefix); other stray `#` lines outside an open item are still dropped. The DASH + ClearKey feature extracts `inputstream.adaptive.license_*` config from `item.raw`, so this delta must survive every upstream sync.
|
||
|
||
There is intentionally **no URL validation** (upstream removed it in 0.15.0): any non-empty non-`#` line after `#EXTINF` becomes the item URL. This is what fixes issue #1189 (Pluto TV JWT URLs longer than validator's 2084-char IE-era limit used to be rejected, and the stalled item index collapsed the whole playlist into one channel). `#` comment lines and unknown directives are appended to `item.raw` and never treated as URLs.
|
||
|
||
The behavioral contract is guarded by `apps/web/src/app/iptv-playlist-parser.contract.spec.ts` (jest maps the module to the real parser source) and by the fork's own test suite.
|
||
|
||
## User-Agent for URL sources
|
||
|
||
The M3U URL form accepts an optional User-Agent, including after an Auto-detect
|
||
handoff. It reuses the playlist editor's translated label and hint. Import
|
||
trims the value; an empty or whitespace-only value leaves download defaults
|
||
unchanged. A successful import stores it as `Playlist.userAgent`, so the
|
||
existing source editor can update it without a schema migration. The editor
|
||
also exposes this field for URL sources in the PWA.
|
||
|
||
Electron carries it in `ElectronBridgePlaylistFetchOptions` alongside TLS
|
||
trust options. `playlist-source.ts` sets the HTTP header on the first fetch,
|
||
startup auto-update supplies each saved playlist's UA, and explicit refresh
|
||
passes it through `PlaylistRefreshPayload` to the refresh worker. The reducer
|
||
retains the current UA when a refresh result omits it, preserving repeated
|
||
refreshes and playback defaults within the same session. Requests
|
||
retain their validated redirect, private-network, TLS, timeout, and cancellation
|
||
policies. Download errors use the shared redactor before logging.
|
||
|
||
The self-hosted PWA passes the optional `userAgent` parameter to `/parse` using
|
||
the existing registered `targetId`; the backend sets the upstream header and
|
||
returns the value in the playlist. Refresh uses the saved value through the
|
||
same proxy. This requires the matching web backend. The browser never sets a
|
||
User-Agent header itself, and this does not add custom browser playback-header
|
||
support. Electron playback retains existing per-header precedence: channel
|
||
headers override playlist defaults. File, text, and portal imports are unchanged.
|
||
|
||
Regression coverage uses synthetic UA-gated HTTP endpoints in
|
||
`playlist-auto-refresh.e2e.ts` (Electron) and `self-hosted.e2e.ts` (PWA), plus
|
||
form, renderer, download, proxy, and refresh-action unit tests.
|
||
|
||
## Initial URL Import Performance Benchmark (Electron)
|
||
|
||
The Electron E2E project has a deterministic initial-import benchmark for
|
||
10,000-, 50,000-, and 100,000-channel M3U playlists. It uses only generated
|
||
fixtures served by an ephemeral `127.0.0.1` HTTP server; provider URLs,
|
||
credentials, and playlist data must never be used. Each formal scenario runs
|
||
one warm-up, five measured iterations, and one diagnostic iteration in a fresh
|
||
Electron process and data directory:
|
||
|
||
```bash
|
||
perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-m3u-import"
|
||
IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \
|
||
IPTVNATOR_PERF_VARIANT=baseline \
|
||
pnpm nx run electron-backend-e2e:benchmark-m3u-import
|
||
```
|
||
|
||
Use a new output directory and `IPTVNATOR_PERF_VARIANT=after` for the identical
|
||
post-change run. Formal runs require a clean worktree and record the commit,
|
||
source-state hash, runtime, and exact fixture identity. A development smoke run
|
||
uses one warm-up, one measured iteration, and one diagnostic iteration per
|
||
size:
|
||
|
||
```bash
|
||
IPTVNATOR_PERF_OUTPUT_DIR="$PWD/dist/performance/<timestamp>-m3u-import-smoke" \
|
||
IPTVNATOR_PERF_VARIANT=smoke \
|
||
IPTVNATOR_PERF_SMOKE=1 \
|
||
pnpm nx run electron-backend-e2e:benchmark-m3u-import
|
||
```
|
||
|
||
Smoke runs may use a dirty worktree and validate only the harness; they cannot
|
||
support a performance claim. If another local application owns port 9222, smoke
|
||
only may set `IPTVNATOR_PERF_CDP_PORT` to an unused loopback port; formal runs
|
||
fail closed unless CDP uses `127.0.0.1:9222`. Raw captures and JSON results stay
|
||
under the gitignored `dist/performance/` tree; preflight rejects a symbolic
|
||
link in any existing output-path component before creating artifacts. Headline
|
||
distributions contain only the five measured runs: warm-up and diagnostic
|
||
profiles are never mixed into them.
|
||
|
||
The benchmark attributes these non-additive intervals:
|
||
|
||
| Field | Boundary |
|
||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||
| `dataAcquireMs` | loopback response acquisition |
|
||
| `m3uParsingMs` | parser call |
|
||
| `normalizationMs` | parsed-item normalization |
|
||
| `mainToRendererCloneProxyMs` | sum of normalized import-result delivery plus the upsert and GET main-response-to-preload-success legs |
|
||
| `storeImportDispatchMs` | renderer import dispatch |
|
||
| `rendererToMainCloneProxyMs` | sum of the upsert and GET preload-source-to-main-request legs |
|
||
| `mainToDatabaseWorkerCloneProxyMs` | sum of the upsert and GET main-request-to-worker-receive legs |
|
||
| `playlistSerializationMs` | database-worker playlist JSON serialization |
|
||
| `sqliteWriteMs` | SQLite upsert, including its autocommit |
|
||
| `sqliteReadMs` | SQLite read of the newly persisted playlist |
|
||
| `playlistDeserializationMs` | database-worker `parseAppPlaylist`, including playlist JSON parsing |
|
||
| `databaseWorkerToMainCloneProxyMs` | sum of the upsert and GET worker-response-post-to-main-response legs |
|
||
| `storePublishChannelsMs` | renderer channel publication |
|
||
| `angularRenderingMs` | publication end to the terminal two-frame paint proof |
|
||
|
||
`ipcStructuredCloneProxyMs` is the explicit sum of the four directional proxy
|
||
fields. Each directional field can combine the applicable initial-result,
|
||
upsert, and `DB_GET_APP_PLAYLIST` legs. The worker stamps
|
||
`responsePostedEpochMs` after request profiling is finalized and immediately
|
||
before each response is posted; the database-worker-to-main proxy ends when
|
||
main receives that response. These fields are attribution aids, not an additive
|
||
waterfall: scheduler work and gaps can remain inside total wall time, and a
|
||
proxy can include response construction, dispatch overhead, and structured
|
||
clone work. Initial import does not create indexes; `indexAndCommitMs` is
|
||
therefore `N/A` with
|
||
`indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write`.
|
||
|
||
Instrumentation is development-only, opt-in, fail-neutral, and count-only. It
|
||
must not scan or log playlist payloads to generate metadata. Renderer
|
||
long-task, frame-gap, and heartbeat samples are clipped to the measured
|
||
operation boundary. The external heartbeat uses a fixed 50 ms deadline grid:
|
||
each renderer delivery records every elapsed deadline, and successful shutdown
|
||
performs one terminal-clipped drain after any in-flight delivery. The capture
|
||
fails closed unless the final sample count matches the complete operation
|
||
grid, preventing coordinated omission when an import blocks several ticks.
|
||
Main capture stops only after the upsert and route-reload GET responses plus
|
||
both asynchronous preload success markers have arrived, so return-clone
|
||
attribution cannot race capture shutdown. Formal comparison fails closed after
|
||
writing raw results if exact-window renderer RSS, database-worker peak
|
||
heap/external samples, or the database worker's explicit post-GC heap is
|
||
missing or incoherent. Both initial-import database requests in every measured
|
||
run must also contain coherent event-loop delay, event-loop utilization, and
|
||
thread-CPU metrics; the validity record exposes the exact expected and valid
|
||
request counts rather than silently dropping nullable samples. An iteration
|
||
failure idempotently stops renderer timers, closes trace listeners/output, and
|
||
starts best-effort probe/session teardown without waiting on a wedged renderer
|
||
before Electron is closed. A partial capture-start failure performs the same
|
||
rollback before it escapes to the benchmark lifecycle.
|
||
Diagnostic artifacts require separate renderer, main, and database-worker CPU
|
||
profiles plus renderer/main/database-worker heap snapshots and a Chromium
|
||
trace; raw profiles remain ignored.
|
||
|
||
## Playlist Refresh And Startup Auto-Update (Electron)
|
||
|
||
Catalog reads through `PlaylistsService.getPlaylist()` join the per-playlist
|
||
mutation queue. Refresh publishes its store action before persistence finishes;
|
||
opening the route in that interval must read after the queued save rather than
|
||
overwrite the refreshed channels with an older SQLite/IndexedDB snapshot.
|
||
Mutation-internal reads use `getPlaylistById()` directly to avoid queue re-entry.
|
||
Failed writes release the queue, and other playlist IDs remain independent.
|
||
|
||
Two paths re-download an M3U playlist from its original source:
|
||
|
||
- **Explicit refresh** — `PLAYLIST_REFRESH` runs in `playlist-refresh.worker.ts`, reports
|
||
progress through `PLAYLIST_REFRESH_EVENT`, and is cancellable via
|
||
`PLAYLIST_CANCEL_REFRESH`. Cancellation is owned by the main process: it first
|
||
sends the cooperative cancel message, then terminates the one-shot worker
|
||
without waiting for its event loop. The cancel IPC resolves only after the
|
||
worker has stopped, the correlated `cancelled` event has been emitted with the
|
||
last known phase, and a structured cancellation result is ready. That result
|
||
crosses both Electron IPC and the context bridge unchanged;
|
||
`PlaylistRefreshService` converts it into a renderer-local `AbortError`.
|
||
Relying on an error created in main or preload would lose its `name` at one of
|
||
those serialization boundaries. A cancelled refresh must not update the
|
||
renderer store or reach SQLite.
|
||
- **Startup auto-update** — after `loadPlaylistsSuccess`, `AppComponent` sends
|
||
`AUTO_UPDATE_PLAYLISTS` for every playlist with `autoRefresh === true`. The main
|
||
process fulfils it in `playlist-auto-update.ts` on top of `playlist-source.ts`.
|
||
|
||
Both share one download contract, because this path is unattended and a hostile or
|
||
dead source must never stall startup (issue #931):
|
||
|
||
- Every HTTP hop uses the idle timeout `PLAYLIST_FETCH_TIMEOUT_MS` (30s, exported by
|
||
`playlist-source.ts`). Without it a host that accepts the connection and then goes
|
||
silent keeps the request pending forever. Redirects are followed one hop at a time,
|
||
so each hop is bounded separately.
|
||
- Auto-update refreshes at most `AUTO_UPDATE_CONCURRENCY` (3) playlists at a time.
|
||
Sequential refreshes let one slow host delay every remaining playlist; an unbounded
|
||
fan-out would download and parse arbitrarily many large M3U files in the main
|
||
process at once.
|
||
- Each playlist's failure is isolated and logged; the successful ones are still
|
||
returned, in the order they were requested. Playlists with neither a URL nor a file
|
||
path are skipped with a warning.
|
||
- `preserveAutoUpdatedPlaylistFields()` re-applies the user-owned fields (`_id`,
|
||
`autoRefresh`, `favorites`, `userAgent`) onto the freshly parsed playlist.
|
||
- Playlist URLs frequently carry Xtream-style `username`/`password` query parameters,
|
||
so refresh logging goes through `redactSensitiveData()` from
|
||
`@iptvnator/shared/logging`.
|
||
|
||
### Refresh Cancellation Performance Regression
|
||
|
||
The Electron E2E project includes a deterministic 100,000-channel cancellation
|
||
benchmark. It uses only a loopback synthetic M3U server, performs one warm-up,
|
||
five measured runs, and one diagnostic run, and writes summaries plus raw
|
||
profiles below the gitignored `dist/performance/` directory:
|
||
|
||
```bash
|
||
perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-m3u-refresh-cancel"
|
||
IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \
|
||
IPTVNATOR_PERF_VARIANT=after \
|
||
pnpm nx run electron-backend-e2e:benchmark-m3u-refresh-cancellation
|
||
```
|
||
|
||
The output path must be an absolute, previously unused descendant of
|
||
`dist/performance/`. A formal run fails on a dirty worktree and records the
|
||
commit, source-state hash, OS/architecture, Node, Electron, and fixture identity
|
||
in its manifest. Commit the harness first and capture `baseline` from that clean
|
||
commit; commit the production change separately, rebuild, and capture `after`
|
||
with the same harness and machine. Set `IPTVNATOR_PERF_SMOKE=1` for one measured
|
||
run during harness development; smoke runs may be dirty and must not support
|
||
before/after claims.
|
||
|
||
The target reserves and verifies CDP port 9222, freezes renderer long-task,
|
||
frame-gap, and heartbeat probes before forced post-GC heap collection, and
|
||
builds the Electron main process, renderer, and workers with optimized,
|
||
source-mapped performance configurations before enabling opt-in worker
|
||
profiling. Renderer RSS is scoped to the Playwright page's exact
|
||
`BrowserWindow` and `webContents`: every sample matches `getOSProcessId()` to
|
||
one exact `app.getAppMetrics()` PID and creation time, while responsive and
|
||
unresponsive events come only from that window. Identity changes, missing or
|
||
ambiguous process metrics, and invalid working-set values fail closed with a
|
||
raw reason and nullable RSS; summaries exclude unavailable RSS instead of
|
||
reporting zero. Formal runs also list every invalid measured capture under
|
||
`summary.validity.rendererRss` and fail after persisting the raw summary unless
|
||
all measured iterations contribute one valid exact-window RSS value. Each
|
||
worker response retains a raw
|
||
request-scoped record containing request/operation identity, received/work/flush
|
||
timestamps, thread CPU, event-loop utilization, event-loop delay, and fixed
|
||
unavailability or invalid reasons. Missing or malformed profiling metadata
|
||
still produces an outcome with its request identity, nullable metrics, and a
|
||
fixed capture-unavailable reason. A worker terminated before it can flush the
|
||
capture reports the metric as unavailable rather than zero.
|
||
|
||
Database-worker post-GC heap is not inferred from a heap snapshot. The harness
|
||
selects exactly one current-generation database worker independently of its
|
||
sampling timer, waits for any in-flight sample plus one final sample, stops the
|
||
worker CPU profile, sends an explicit-GC one-shot probe over a transferred
|
||
`MessagePort`, and takes an optional diagnostic snapshot only afterward.
|
||
Capture stop first closes a synchronous request cutoff and stops the main CPU
|
||
profile, so worker profile writes and heap snapshots cannot appear as
|
||
application stacks in `main.cpuprofile`. A database request observed after that
|
||
cutoff cannot restart worker sampling or profiling; it records
|
||
`database-worker-activity-after-cutoff` and invalidates the post-GC value.
|
||
Worker artifacts include a stable isolate ordinal in their filename so an
|
||
invalid multiple-worker capture cannot overwrite another isolate's profile.
|
||
The raw heap and unavailability reason form a strict XOR; playlist workers
|
||
terminated during cancellation report `worker-force-terminated-before-gc`
|
||
instead of a snapshot-derived heap. Warm-up and measured cancellation invoke
|
||
the real worker termination before best-effort finalization so profiling cannot
|
||
delay headline acknowledgement metrics. The separate diagnostic iteration
|
||
stops its CPU profile before termination; its timings are excluded from
|
||
headline distributions. That pre-termination drain is bounded; timeout
|
||
invalidates the profile and worker termination still proceeds. Late
|
||
termination completions are generation-gated so they cannot mutate a reused
|
||
record or append timeline events after an atomic capture rollover. The
|
||
benchmark fails closed unless the diagnostic iteration observed cancellation,
|
||
its profile is inside the iteration directory and parses with non-empty nodes
|
||
and samples, and the dying worker has no heap snapshot.
|
||
|
||
Headline worker memory distributions include every matching isolate and
|
||
exclude only nullable unavailable values. This aggregation is not used as a
|
||
validity shortcut. Runs that reach database persistence must independently
|
||
contain exactly one database worker with a coherent numeric post-GC heap.
|
||
Parsing cancellation happens before the renderer dispatches persistence, so a
|
||
run with no database request is explicitly `N/A:
|
||
operation-cancelled-before-database-phase`, not a missing capture. Any database
|
||
activity in a run that observed the cancellation effect is unrelated
|
||
contamination and invalidates the run. Duplicate, busy, late, timed-out, or
|
||
malformed captures are listed under
|
||
`summary.validity.databaseWorkerPostGc` and invalidate the benchmark. The
|
||
benchmark writes `manifest.json` and `summary.json` first, then fails the formal
|
||
run so the raw evidence remains available without supporting a before/after
|
||
claim. A formal cancellation run also requires the cancellation effect in every
|
||
measured iteration. Database-worker values are comparable only when every
|
||
measured iteration reaches that phase and has one valid capture.
|
||
|
||
Seed setup runs inside its own capture generation. The harness requires an
|
||
observed seed database request, a completed playlist upsert, and no pending
|
||
request, then persists that generation as `seed-main-capture.json`. Main
|
||
capture finalization validates one idle database worker with a coherent
|
||
explicit-GC result and atomically rolls the clean cutoff into the measured
|
||
generation before yielding. A request before rollover remains late and rejects
|
||
the transition; a request after rollover belongs to the measured generation.
|
||
This prevents seed writes or an unobserved stop/start gap from crossing into
|
||
measured main RSS, worker peaks, or profiles; generation selection also
|
||
excludes the seed worker record. Since the production M3U database payload
|
||
deliberately omits the refresh operation ID, the benchmark correlates only a
|
||
valid preload `start` marker to the exact database operation and playlist. A
|
||
missing or ambiguous marker leaves the raw operation ID `null` with a fixed
|
||
reason; it does not infer identity from timing alone.
|
||
|
||
Headline worker fields named `*WorkerRequest*` are distributions of individual
|
||
request metrics. In particular, request p95/p99 distributions are not presented
|
||
as an operation-wide or process-wide percentile, and database request
|
||
percentiles are never collapsed with `Math.max`. Diagnostic CPU profiles, heap
|
||
snapshots, and Chromium traces are excluded from the five-run headline
|
||
distributions.
|
||
|
||
### Reporting The Auto-Update Result
|
||
|
||
Because auto-update isolates failures, it must also report them — otherwise a
|
||
dropped playlist is indistinguishable from a successful refresh. `autoUpdatePlaylists()`
|
||
returns `AutoUpdatePlaylistsResult`
|
||
(`libs/shared/interfaces/src/lib/playlist-auto-update.interface.ts`): the refreshed
|
||
`playlists` plus one `outcome` per requested playlist, in request order.
|
||
|
||
| Outcome status | Meaning |
|
||
| -------------- | ------------------------------------------------------------------------------- |
|
||
| `updated` | Source fetched/read and parsed; playlist is in `playlists` |
|
||
| `failed` | Source unreachable, unreadable or unparsable |
|
||
| `skipped` | Playlist has neither a URL nor a file path, so there is nothing to refresh from |
|
||
|
||
`ElectronService` dispatches `updateManyPlaylists` with the refreshed playlists only,
|
||
logs the titles of unresolved playlists, then derives the snackbar from those outcomes
|
||
(`apps/web/src/app/services/auto-update-playlists-feedback.ts`):
|
||
`AUTO_REFRESH_UPDATE_SUCCESS` when everything updated,
|
||
`AUTO_REFRESH_UPDATE_PARTIAL` / `AUTO_REFRESH_UPDATE_FAILED` (error styling,
|
||
dismissable) when some or all playlists failed, and `AUTO_REFRESH_UPDATE_SKIPPED` when
|
||
the only unrefreshed playlists lacked a source. A batch that both failed and skipped
|
||
uses `AUTO_REFRESH_UPDATE_PARTIAL_WITH_SKIPPED`, because the plain partial message names
|
||
only `updated`/`total`/`failed` and would leave the skipped playlists as an unexplained
|
||
remainder. Every count the user sees must reconcile with `total`, and success is never
|
||
reported unconditionally — a silently dropped playlist used to look like a successful
|
||
refresh.
|
||
`playlist-auto-refresh.e2e.ts` guards this by restarting the app against a killed
|
||
playlist server.
|
||
|
||
## State Management (libs/m3u-state/)
|
||
|
||
### State Structure
|
||
|
||
```typescript
|
||
// libs/m3u-state/src/lib/state.ts
|
||
interface PlaylistState {
|
||
active: Channel | undefined; // Active channel being played
|
||
activePlaybackUrl: string | null;
|
||
activeEpgProgram: EpgProgram | undefined;
|
||
currentEpgProgram: EpgProgram | undefined;
|
||
epgAvailable: boolean;
|
||
channelsLoading: boolean; // Route still resolving channel data
|
||
channels: Channel[]; // All channels from current playlist
|
||
playlists: PlaylistMetaState; // Playlist metadata (entity adapter)
|
||
}
|
||
|
||
// libs/m3u-state/src/lib/playlists.state.ts
|
||
interface PlaylistMetaState extends EntityState<PlaylistMeta> {
|
||
selectedId: string;
|
||
allPlaylistsLoaded: boolean;
|
||
selectedFilters: string[]; // 'm3u' | 'xtream' | 'stalker'
|
||
}
|
||
```
|
||
|
||
`PlaylistMeta` is the persisted playlist-facing subset of the playlist entity.
|
||
For M3U playlists it now also carries `hiddenGroupTitles?: string[]`, which is
|
||
used by the groups view to remember which group titles the user has hidden.
|
||
|
||
### Actions
|
||
|
||
| Action Group | Actions | Purpose |
|
||
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------ |
|
||
| **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD |
|
||
| **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation |
|
||
| **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state |
|
||
| **FavoritesActions** | `updateFavorites`, `setFavorites`, `hydrateFavorites` | Favorites management |
|
||
| **FilterActions** | `setSelectedFilters` | Playlist type filtering |
|
||
|
||
### Key Selectors
|
||
|
||
```typescript
|
||
// Channel selectors
|
||
selectActive; // Current playing channel
|
||
selectChannelsLoading; // Channel list loading flag
|
||
selectChannels; // All channels array
|
||
selectFavorites; // Favorite channel URLs
|
||
|
||
// Playlist selectors
|
||
selectAllPlaylistsMeta; // All playlists
|
||
selectActivePlaylistId; // Selected playlist ID
|
||
selectActivePlaylist; // Active playlist object
|
||
selectPlaylistTitle; // Title with "Global favorites" fallback
|
||
|
||
// EPG selectors
|
||
selectIsEpgAvailable; // EPG data available flag
|
||
selectCurrentEpgProgram; // Current playing program
|
||
```
|
||
|
||
## Channel List Container
|
||
|
||
**Location**: `libs/ui/components/src/lib/channel-list-container/`
|
||
|
||
### Component Architecture
|
||
|
||
```
|
||
channel-list-container/
|
||
├── channel-list-container.component.ts # Parent - shared state coordinator
|
||
├── channel-list-container.component.html
|
||
├── channel-list-container.component.scss
|
||
│
|
||
├── all-channels-view/ # Virtual scroll + debounced search
|
||
│ ├── all-channels-view.component.ts
|
||
│ ├── all-channels-view.component.html
|
||
│ └── all-channels-view.component.scss
|
||
│
|
||
├── groups-view/ # Expansion panels + infinite scroll
|
||
│ ├── groups-view.component.ts
|
||
│ ├── groups-view.component.html
|
||
│ └── groups-view.component.scss
|
||
│
|
||
├── favorites-view/ # Drag-drop reordering
|
||
│ ├── favorites-view.component.ts
|
||
│ ├── favorites-view.component.html
|
||
│ └── favorites-view.component.scss
|
||
│
|
||
├── recent-view/ # Recently viewed channels
|
||
│ ├── recent-view.component.ts
|
||
│ ├── recent-view.component.html
|
||
│ └── recent-view.component.scss
|
||
│
|
||
└── channel-list-item/ # Individual channel display
|
||
├── channel-list-item.component.ts
|
||
├── channel-list-item.component.html
|
||
└── channel-list-item.component.scss
|
||
```
|
||
|
||
### Data Flow
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────┐
|
||
│ ChannelListContainerComponent │
|
||
│ (Parent) │
|
||
│ ┌────────────────────────────────────────────────────────┐ │
|
||
│ │ Shared State (Signals): │ │
|
||
│ │ - channelEpgMap: Map<string, EpgProgram> │ │
|
||
│ │ - progressTick: number (30s interval) │ │
|
||
│ │ - shouldShowEpg: boolean │ │
|
||
│ │ - favoriteIds: Set<string> │ │
|
||
│ └────────────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌─────────────────────┼─────────────────────┐ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌─────────┐ ┌──────────┐ ┌───────────┐ │
|
||
│ │ All │ │ Groups │ │ Favorites │ │
|
||
│ │Channels │ │ Tab │ │ Tab │ │
|
||
│ │ Tab │ │ │ │ │ │
|
||
│ └────┬────┘ └────┬─────┘ └─────┬─────┘ │
|
||
│ │ │ │ │
|
||
│ └───────────────────┴─────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ (channelSelected) output │
|
||
│ │ │
|
||
└──────────────────────────┼───────────────────────────────────┘
|
||
▼
|
||
Store Dispatch
|
||
ChannelActions.setActiveChannel
|
||
```
|
||
|
||
### Loading States
|
||
|
||
- `M3uWorkspaceRouteSession` owns route-driven channel loading for the player/sidebar routes: `all` and `groups`.
|
||
- The route session sets `channelsLoading` before `getPlaylist()` resolves and clears it when `ChannelActions.setChannels` lands.
|
||
- The route session dispatches reducer-only `FavoritesActions.hydrateFavorites`
|
||
after that persisted read. Hydration must not use the persistence-bearing
|
||
`setFavorites` action: doing so reads and rewrites the complete M3U payload
|
||
again just to store favorites that already came from SQLite.
|
||
- `ChannelListContainerComponent` now renders a dedicated skeleton state while `channelsLoading` is true.
|
||
- `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation.
|
||
- The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope.
|
||
- M3U favorites and recent collection rows preserve their full `Channel` payload on unified live items so the shared live list can open the read-only channel details context menu without reconstructing partial channel data.
|
||
- Recent live rows support context-menu removal in the unified all-playlists view; the row-level delete shortcut remains available on playlist-scoped M3U recent rows.
|
||
- Empty playlists and empty search results are no longer conflated:
|
||
- loading: skeletons
|
||
- empty source: no channels in the playlist after loading completes
|
||
- empty search: no matches within an already loaded playlist
|
||
|
||
### Group Visibility Management
|
||
|
||
- `GroupsViewComponent` owns the M3U-only "Manage groups" action and dialog in
|
||
`libs/ui/components/src/lib/channel-list-container/groups-view/`.
|
||
- The groups rail header also owns an inline search toggle that filters the
|
||
currently visible groups without mutating the workspace-level route search
|
||
term used by the broader channel views.
|
||
- The dialog operates on the full grouped dataset, while the left rail and
|
||
channel pane render only groups whose titles are not listed in
|
||
`hiddenGroupTitles`.
|
||
- `ChannelListContainerComponent` reads `hiddenGroupTitles` from the active M3U
|
||
playlist metadata and passes it into the groups view. Saving dialog changes
|
||
dispatches `PlaylistActions.updatePlaylistMeta`.
|
||
- `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.
|
||
- 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.
|
||
|
||
### Channel Sorting
|
||
|
||
- `AllChannelsViewComponent` owns sorting for the all-channels list and
|
||
persists the selected mode under `m3u-all-channels-sort-mode`.
|
||
- `GroupsViewComponent` owns sorting for the selected group's channel pane and
|
||
persists the selected mode under `m3u-groups-channel-sort-mode`.
|
||
- Both views support three modes: `Playlist Order`, `Name A-Z`, and `Name Z-A`.
|
||
`Playlist Order` is the default and preserves the original channel order from
|
||
the M3U playlist.
|
||
- Sorting is applied after the current search filter and before virtual-scroll
|
||
rendering. Playlist order avoids cloning the full list when no search term is
|
||
active.
|
||
|
||
### ChannelEpgMetadata Pattern
|
||
|
||
For performance optimization, EPG data is kept in a side-car map instead of
|
||
being cloned onto every channel (the older `EnrichedChannel` pattern that
|
||
spread-cloned every channel on every ~30 s tick was removed —
|
||
`channel-list-container/epg-enrichment.util.ts`):
|
||
|
||
```typescript
|
||
// libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.ts
|
||
interface ChannelEpgMetadata {
|
||
epgProgram: EpgProgram | null | undefined;
|
||
progressPercentage: number; // Pre-computed by parent
|
||
}
|
||
```
|
||
|
||
The renderer now keeps two lookup maps for M3U collection views:
|
||
|
||
- `channelEpgMap` for current-program preview data
|
||
- `channelIconMap` for XMLTV channel icon fallback data
|
||
|
||
Logo resolution is runtime-only and follows this rule:
|
||
|
||
1. playlist `tvg-logo`
|
||
2. matched XMLTV `<channel><icon src="...">`
|
||
3. generic `live_tv` fallback in the list item component
|
||
|
||
EPG lookup keys use the same precedence in both program and icon paths:
|
||
|
||
1. `tvg-id`
|
||
2. `tvg-name`
|
||
3. channel name
|
||
|
||
All four collection tabs (all-channels, groups, favorites, recent) share a single
|
||
EPG-enrichment implementation in `channel-list-container/epg-enrichment.util.ts`,
|
||
fed the same `channelEpgMap`/`channelIconMap` from the container — there is no
|
||
per-tab EPG logic:
|
||
|
||
- `calculateEpgProgress(program, now?)` — clamped, **rounded** progress in
|
||
`[0, 100]`, guarded against missing/invalid timestamps and zero-length
|
||
programmes (never returns `NaN`).
|
||
- `resolveChannelEpgProgram(channel, channelEpgMap)` — the current programme for
|
||
a channel by its lookup key (used by the per-item recent view).
|
||
- `buildChannelEpgMetadataMap(channelEpgMap, now?)` — the side-car
|
||
`key → {epgProgram, progressPercentage}` map (used by all-channels/groups/
|
||
favorites). Callers read their `progressTick()` signal first so the computed
|
||
re-runs on the ~30s tick.
|
||
|
||
### EPG Panel (Timeline & List views)
|
||
|
||
The programme guide under the player renders in one of **two interchangeable
|
||
views**, chosen by the **`epgViewMode`** setting (`'timeline'` default, or
|
||
`'list'`; Settings → EPG → _Guide view_):
|
||
|
||
- **Timeline** — a horizontal **ribbon** (`app-epg-timeline`,
|
||
`libs/ui/epg/src/lib/epg-timeline/`).
|
||
- **List** — a vertical, single-day **programme list** (`app-epg-list-view`,
|
||
`libs/ui/epg/src/lib/epg-list-view/`) with a prev/today/next stepper.
|
||
|
||
Both are shared by all four live surfaces: the M3U video player, the unified live
|
||
tab, and the Xtream and Stalker live-stream layouts (replacing the former
|
||
vertical `app-epg-list` / `app-epg-view`). `EpgListViewComponent` mirrors
|
||
`EpgTimelineComponent`'s input/output contract **1:1**, so each host swaps them
|
||
with a plain `@if (epgViewMode() === 'list') { <app-epg-list-view … /> } @else {
|
||
<app-epg-timeline … /> }` — identical bindings in both branches. Hosts read
|
||
`epgViewMode` from `SettingsStore` (a signal), so flipping the setting swaps the
|
||
panel live. The setting flows end-to-end (`Settings.epgViewMode` →
|
||
`DEFAULT_SETTINGS` → `SettingsStore`/`StorageMap` → the segmented control in
|
||
`settings-epg-section`) and needs no backend change. The control is
|
||
**Electron-only in practice** — the EPG settings section (and the form control)
|
||
is gated behind `supportsEpg`, which is false in PWA; there the stored value
|
||
simply stays at the `'timeline'` default.
|
||
|
||
### EPG display offset
|
||
|
||
EPG display times also support a global **`Settings.epgOffsetMinutes`**
|
||
correction (Settings → EPG → _EPG time offset_; whole minutes, clamped to ±720,
|
||
default 0) for guides whose provider labels programme times with the wrong
|
||
timezone. It is **display-only**: parsed XMLTV values, SQLite rows, catch-up
|
||
URLs and recording snapshots keep the provider's own times, so changing it
|
||
never needs a guide refresh.
|
||
|
||
The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts`
|
||
and has two equivalent forms — **display** (`epgDisplayTimeMs`: shift a
|
||
programme by `+offset`, then compare with wall-clock now or format it) and
|
||
**clock** (`epgProviderClockMs`: express now as `now − offset` and compare with
|
||
the raw programme times). Every consumer applies exactly one form per
|
||
comparison; applying both doubles the shift.
|
||
|
||
- **Display form** — everything in `ui/epg` (`getProgramTimeMs` feeds the
|
||
timeline geometry, list rows, date keys, dedup/equality, the collapsed
|
||
header's range and progress) receives the offset as the `offsetMinutes`
|
||
input from each host; `app-channel-list-item`, the dashboard time range and
|
||
the recording detail shift their labels the same way. The programme dialog
|
||
and the programme guide are opened imperatively, so they read
|
||
`SettingsStore.resolvedEpgOffsetMinutes` themselves.
|
||
- **Clock form** — every "currently airing" decision, so the row picked as
|
||
"now" is the one the UI renders as "now": the batched
|
||
`GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs`
|
||
(`EpgService` passes its provider clock and tags its 60 s cache with the
|
||
offset; `ChannelListContainerComponent` refetches when the setting changes),
|
||
the channel-list progress bars, `XtreamStore.currentEpgItem`, the Xtream and
|
||
Stalker sidebar previews (re-picked on a change; whenever the offset is
|
||
non-zero the Xtream preview queue and the collection resolver cut their
|
||
window out of the full guide at the provider clock with
|
||
`windowEpgItemsAtProviderClock`, because `get_short_epg` always starts at
|
||
the provider's own "now" and cannot reach the programme actually on air; a
|
||
changed setting drops the queue's cut windows, cached empty results and
|
||
failure cooldowns as one, and a request that completes after the change is
|
||
retired and re-queued whatever its outcome), the M3U player's
|
||
`setCurrentEpgProgram` mirror and recording-start snapshot, the unified
|
||
collection resolver (`StreamResolverService.loadEpgForItems`), the dashboard
|
||
live cards' progress, and the recording stop-time overlap
|
||
(`filterRecordingProgramsOverlap`).
|
||
|
||
Deliberately unshifted: catch-up URLs (built from the raw `programActivated`
|
||
programme), recording snapshots (provider data; only their display is
|
||
shifted), and the remote-control payload. Known limit: Stalker's bulk
|
||
`get_epg_info` and `get_short_epg` also start at the portal's "now" and the app
|
||
uses no past-reaching Stalker endpoint, so with a positive offset a Stalker
|
||
sidebar row whose true programme lies in the portal's past shows no preview
|
||
rather than a wrong one; under a negative offset the short-EPG window is widened
|
||
instead (`shortEpgWindowSize`, 15-minute slots, capped at 50 — the sidebar queue,
|
||
the active channel's panel fallback and the collection resolver all use it) so
|
||
it still reaches the programme on air, and preview cache entries are tagged with
|
||
the offset they were fetched for. Like the view mode, the control is Electron-only in practice —
|
||
it sits behind `supportsEpg`.
|
||
|
||
Both components stay presentation-focused; the reusable, view-agnostic pieces
|
||
(shared by the timeline and the list) are split out and re-exported from
|
||
`@iptvnator/ui/epg`:
|
||
|
||
- `epg-timeline.utils.ts` (axis/blocks/date helpers) + `epg-timeline-render.util.ts`
|
||
(short-programme tiers, grouping, zoom bounds) — the ribbon geometry.
|
||
- `epg-archive.util.ts` — catch-up gating (`isWithinArchiveWindow`,
|
||
`canCatchUpProgramme`, `epgDialogActionFor`) off `when`/`startMs` primitives.
|
||
- `epg-summary.util.ts` — `EpgTimelineSummary` + collapsed-summary progress maths
|
||
(`summaryProgress` / `summaryMinutesLeft` / …).
|
||
- `epg-programme-dialog.service.ts` — `EpgProgrammeDialogService`, opens the
|
||
shared details dialog and returns the chosen `live` / `timeshift` action.
|
||
- `epg-timeline-scroll.controller.ts` — `TimelineScrollController` (ribbon
|
||
scrolling + channel-select auto-focus); timeline-specific, kept out of the
|
||
component so it stays under the line ceiling.
|
||
|
||
The **list view** (`epg-list-view/`) composes those same shared modules — it does
|
||
**not** duplicate classification or gating logic. It reuses `classifyTimelineWhen`
|
||
/ `hasProgramsForDateKey` / `nearestDateKeyWithPrograms`, `epg-archive.util`,
|
||
`epg-summary.util`, the `epg-date` helpers, `EpgProgrammeDialogService`, and the
|
||
shared `app-epg-timeline-empty-state` — and drops all ribbon geometry, zoom, and
|
||
horizontal scroll. It filters the loaded window to the selected day (overlap-based,
|
||
matching `hasProgramsForDateKey`), sorts, and deduplicates via a pure
|
||
`buildEpgListRows` (`epg-list-view.utils.ts`); renders each row through the dumb
|
||
`app-epg-list-view-row`; and delegates its own vertical auto-focus + sticky
|
||
"now" strip to `EpgListScrollController` (`epg-list-scroll.controller.ts`). Render
|
||
states, the collapsed inline summary, the date stepper, catch-up/timeshift
|
||
activation, and the details dialog behave identically to the timeline. Both
|
||
views also carry the optional `guideAvailable` input and `openGuide` output:
|
||
the M3U host binds them in both branches, so the programme guide's Guide
|
||
action is reachable whichever view the setting selects.
|
||
|
||
- **One channel, preloaded window.** The panel always shows a single channel.
|
||
Each provider returns a multi-day window in roughly one call (M3U
|
||
`GET_CHANNEL_PROGRAMS`; Stalker `get_epg_info`; Xtream `get_simple_data_table`),
|
||
so the whole ribbon is rendered up front and day navigation is **scroll within
|
||
the loaded window** — no per-day lazy fetch. The date stepper / "Now" jump
|
||
scroll the ribbon; the day label follows the scroll position.
|
||
- **Auto-focus on channel select.** When a channel's EPG (re)loads or the ribbon
|
||
(re)mounts, the timeline centres the **currently airing programme** in the
|
||
viewport **instantly** (`behavior: 'auto'`, no scroll animation) — selecting a
|
||
channel lands on "now" without the user pressing the Now button. The jump is
|
||
deduped by programme-set identity (`programsFocusKey`), so the 30s now-tick,
|
||
zoom changes, or a host re-emitting the same data never re-jump the viewport;
|
||
switching channels (or returning after viewing an empty-day channel) re-centres.
|
||
The explicit "Now" button still animates (`behavior: 'smooth'`) since it is a
|
||
deliberate user action. See `TimelineScrollController.maybeAutoFocus` /
|
||
`focusCurrentProgram` in `epg-timeline-scroll.controller.ts`.
|
||
- **Controlled component.** `app-epg-timeline` is presentation-only: it takes
|
||
`programs`, `archivePlaybackAvailable`, `archiveDays`, `activeProgram`,
|
||
`isLivePlayback`, `loading`, `emptyReason`, `selectedDate`, `collapsed`,
|
||
`summary` and emits `programActivated`, `returnToLive`, `selectedDateChange`,
|
||
`openEpgSettings`, `retry`, `collapsedChange`. The host layout owns playback,
|
||
persists the collapse state (`live-epg-panel-state` in localStorage), and (for
|
||
the M3U player) the `EpgActions.setCurrentEpgProgram` / `setEpgAvailableFlag`
|
||
/ `setActiveEpgProgram` dispatches. The timeline owns the **single** panel
|
||
bar — collapse chevron + channel name on the left, return-to-live / jump /
|
||
date stepper on the right — and the collapsed inline summary; the former
|
||
`app-live-epg-panel` wrapper has been removed from the live layouts.
|
||
- **Dynamic bar subtitle.** Under the channel name the bar shows the
|
||
**now-playing programme title** when expanded and a `summary` exists
|
||
(`.epg-timeline__subtitle`) — readable title style, not the uppercase mono
|
||
label. During timeshift it switches to the archive programme with a `history`
|
||
icon and cyan accent (`.is-arch`). It falls back to the static `sourceLabel`
|
||
(`Timeline` / `Xtream` / `Stalker Portal`) only when collapsed or when the
|
||
channel has no programme.
|
||
- **State-aware toolbar controls.** The right-side controls are **hidden** (not
|
||
disabled) when they cannot act, so a channel with no EPG shows a clean bar
|
||
instead of dead controls: `showRibbonControls()` gates "Now" + zoom to the
|
||
`ribbon` state only (nothing to jump to or zoom otherwise), and
|
||
`showDateStepper()` keeps the date stepper for `ribbon` **and** `empty-day`
|
||
(the only states where the channel has EPG on some day), hiding it for the
|
||
no-EPG-anywhere states and while loading. Return-to-live is a playback control
|
||
(`!isLivePlayback()`) and is independent of EPG state.
|
||
- **State-driven affordances.** Blocks are coloured past / now / future, with a
|
||
red "now" playhead. Catch-up "Watch" appears on past blocks — and as a
|
||
start-over replay button on the currently-airing block — only when
|
||
`archivePlaybackAvailable` (Xtream `tv_archive`, M3U `catchup-*`); Stalker is
|
||
schedule-only (dimmed past + a notice, no false buttons). The "i" button opens
|
||
the shared `app-epg-item-description` dialog with a state-aware action.
|
||
- **Empty / error states.** `emptyReason` selects one of six states
|
||
(`loading` skeleton, `empty-day`, `channel-unmapped`, `provider-no-epg`,
|
||
`m3u-needs-setup`, `error`) via `app-epg-timeline-empty-state`. Icon tone is
|
||
neutral for info, blue for actionable, red for errors; an action button is
|
||
shown only when one really exists. The empty-state host `flex: 1`-fills the
|
||
area below the toolbar and centres within **that** (compact icon/title/sub),
|
||
rather than a fixed `min-height` that used to overflow the compact inline panel
|
||
and push the icon/text — and the `empty-day` action buttons — below the visible
|
||
edge. `empty-day` itself is decided by `hasProgramsForDateKey`, which is
|
||
**overlap-based** (a programme counts for a day when `[start, stop)` intersects
|
||
it, end-exclusive) — so a film that starts the previous evening and runs past
|
||
midnight still keeps the ribbon on "today" while it airs, matching the sidebar
|
||
(which matches by "airing now"); a start-date-only check used to drop to
|
||
`empty-day` after midnight even though the programme was on air.
|
||
- **Short-programme strategy.** In a proportional ribbon a 5-minute programme
|
||
would be an unreadable sliver, so `buildTimelineRenderItems`
|
||
(`epg-timeline.utils.ts`) applies a layered fix: (A) a **minimum block width**
|
||
(`TIMELINE_MIN_BLOCK_WIDTH_PX`); (B) width-adaptive **content tiers** —
|
||
`wide` (title + time, 3-line clamp) → `med` (one-line ellipsis) → `narrow`
|
||
(**vertical title**, no time) → `micro` (just a marker); (C) a **hover/focus
|
||
popover** revealing the full title + time + description for any non-`wide`
|
||
block (it flips above the block when the panel is near the screen bottom);
|
||
(D) a px-per-minute **zoom** (tick density adapts via
|
||
`timelineTickStepForScale`): the toolbar's icon-only zoom button cycles
|
||
three presets — day overview (`1`) → by hour (`1.75`, the default) →
|
||
detailed (`3.4`, the scale a group chip expands to) — snapping a
|
||
wheel-tuned scale to its band's successor (`TIMELINE_ZOOM_LEVELS`,
|
||
`nextTimelineZoomScale`), while Ctrl/⌘ + wheel (and trackpad pinch) over
|
||
the ribbon zooms continuously around the cursor within
|
||
`TIMELINE_ZOOM_MIN..MAX` and `preventDefault`s so Chromium never page-zooms
|
||
the same gesture; the current level lives in the tooltip/`aria-label` and
|
||
a `data-zoom-level` attribute; and (E) **grouping** of ≥4 consecutive short
|
||
(<10 min) programmes into one dashed "N short" chip when zoomed out
|
||
(`scale < TIMELINE_GROUP_ZOOM_MAX`), expanded by clicking it. The ribbon
|
||
canvas lives in the child `app-epg-timeline-track`; the parent owns the
|
||
scroller, toolbar (icon-only "Now" + zoom buttons, both labelled through
|
||
tooltip + `aria-label`, so the channel/programme heading takes every pixel
|
||
the fixed-width controls leave) and state.
|
||
- **Panel height & titles.** Block titles wrap onto as many lines as the card
|
||
height allows and are clipped (not single-line ellipsis); the foot ("ON NOW"
|
||
tag / "Watch") stays pinned at the bottom. With an inline player the guide is
|
||
a compact panel (`.epg.epg--inline` → `flex: 0 0 clamp(180px, 36vh, 264px)`
|
||
in `_portal-layout.scss`) so the player stays dominant; with an external
|
||
player the guide keeps `flex: 1` and fills the whole content area. In **list
|
||
mode** the hosts also set `.epg--list`, which raises only the inline clamp
|
||
(`--epg-inline-height: clamp(280px, 46vh, 430px)`) — vertical rows need more
|
||
height than the ribbon; the timeline height is unchanged, and the collapsed
|
||
56px clamp still wins because the modifier sets just the CSS variable.
|
||
- **Wide-tier description preview.** When a block is the `wide` tier (rendered
|
||
width ≥ `132px`, i.e. long programmes and/or zoomed in) **and** the programme
|
||
has a `desc`, a dimmed (`--text-secondary`) preview of the description renders
|
||
under the title (`.epg-timeline__block-desc`). Gated to `wide` only, so
|
||
narrower cards and the moderate default zoom stay clean; the full description
|
||
still lives in the hover popover for every tier. To avoid ugly mid-line cuts,
|
||
wide-tier `time`/`title`/`desc` use `flex-shrink: 0` so flexbox can never
|
||
shrink them to a fractional height: each self-clips on **whole lines** with an
|
||
ellipsis via `-webkit-line-clamp` (title ≤ 2 lines, description ≤ 3) instead of
|
||
being cut mid-line by the parent's `overflow: hidden`. At the usual inline
|
||
panel height the title + 3-line preview fit without the parent clipping at all.
|
||
|
||
### Playlist-Declared EPG Sources
|
||
|
||
Some M3U providers declare XMLTV sources in the playlist header instead of
|
||
requiring the user to add them in Settings. The importer extracts EPG URLs from
|
||
`#EXTM3U` header attributes `x-tvg-url`, `url-tvg`, and `tvg-url` in
|
||
`@iptvnator/shared/m3u-utils`, then stores the normalized, deduplicated
|
||
candidates on `Playlist.detectedEpgUrls`.
|
||
|
||
`Playlist.epgUrls` is the enabled playlist-scoped subset used for automatic
|
||
import and lookup. Two additional lists preserve user edits:
|
||
|
||
- `Playlist.manualEpgUrls` stores URLs the user explicitly added for this
|
||
playlist, including detected catalog URLs the user manually enabled.
|
||
- `Playlist.disabledEpgUrls` stores detected URLs the user removed from this
|
||
playlist so playlist refreshes do not silently re-enable them.
|
||
|
||
- Up to five detected URLs are enabled automatically.
|
||
- Larger header lists are treated as provider catalogs. The importer keeps all
|
||
candidates in `detectedEpgUrls`, but auto-enables only recommended URLs whose
|
||
`guides/<country>` path matches playlist hints such as `tvg-country` or the
|
||
country suffix in `tvg-id` (`channel.ua`). Language hints are used only when no
|
||
country hints are present. If no recommendation can be made, the importer
|
||
falls back to the first five detected URLs so generic provider catalogs still
|
||
produce usable local EPG sources instead of silently enabling none.
|
||
- Recommendations are capped so a malformed or global provider list cannot
|
||
start dozens of XMLTV downloads during playlist import.
|
||
|
||
These URLs are playlist-scoped by default:
|
||
|
||
- `libs/m3u-state` auto-fetches enabled `epgUrls` when M3U playlists are
|
||
loaded, added, or refreshed, using the same EPG progress/import pipeline as
|
||
Settings-managed XMLTV URLs. Before fetching, playlist URLs already present in
|
||
global Settings are filtered out so the same XMLTV URL is not downloaded
|
||
twice. Within a running session, the effect remembers the last fetchable URL
|
||
set per playlist and only re-fetches when that URL set changes; metadata-only
|
||
edits such as renaming a playlist or hiding groups do not re-download local
|
||
EPG sources. When the local URL set expands, only newly added fetchable URLs
|
||
are downloaded; disabling or removing one source does not re-download the
|
||
remaining sources. Explicit playlist refreshes bypass that session fetch key
|
||
and re-download the current fetchable local EPG URLs. Partial metadata updates
|
||
that omit `epgUrls` preserve the previous fetch key, while an explicit empty
|
||
`epgUrls` list clears it. Add/update metadata effects trigger playlist-local
|
||
EPG fetches only after the playlist persistence call succeeds, and metadata
|
||
updates that do not include any EPG source fields do not evaluate the fetch
|
||
plan.
|
||
- The Electron EPG database stores `source_url` on imported programs so current
|
||
program lookups can ask for the active playlist's EPG sources first. Existing
|
||
databases backfill this column from `epg_channels.source_url` once, in bounded
|
||
batches, after the scoped indexes are created. When multiple EPG files reuse
|
||
the same XMLTV channel id, the channel row keeps its original `source_url`
|
||
attribution instead of being overwritten by the last imported source; program
|
||
scoping remains source-specific through `epg_programs.source_url`.
|
||
- `ChannelListContainerComponent` enables EPG rows when either global settings
|
||
URLs or the active M3U playlist has `epgUrls`. EPG availability refreshes are
|
||
debounced so several playlist-local XMLTV imports completing close together
|
||
coalesce into one visible-channel EPG refresh. The visible channel list also
|
||
refreshes when the effective EPG source context changes, so a playlist whose
|
||
`epgUrls` arrive after the channels are rendered does not wait for the next
|
||
periodic refresh before showing current programs. A successful EPG import
|
||
clears current-program lookup caches before publishing availability, so an
|
||
early "no current program" lookup cannot mask freshly imported rows until the
|
||
TTL expires.
|
||
- Scoped lookups fall back only to Settings-managed EPG URLs for channels
|
||
missing from the playlist-declared source. Playlist-local sources from other
|
||
playlists are not treated as global fallback sources. The one opt-out is
|
||
`EpgLookupOptions.anySourceFallback` (renderer-only, never forwarded to the
|
||
bridge): after the scope — playlist sources, then the global ones — has
|
||
answered, the keys still without a programme are retried once against every
|
||
imported source through the source-less batch path and its cache. The
|
||
ladder has the same shape with or without the bridge's batch endpoint: on
|
||
an older preload the scoped pass runs as per-channel scoped lookups
|
||
(`getScopedCurrentProgramForChannel`, same scope -> fallback-scope walk,
|
||
same scoped cache key) and only the any-source retry is source-less.
|
||
Collapsing that preload straight into the source-less lookup would drop
|
||
the caller's scope, which is what the scopes exist to prevent. The
|
||
dashboard live
|
||
rails pass the option: without it a favourite whose guide only exists in
|
||
another playlist's XMLTV showed no programme on the dashboard while its
|
||
"See all" row — resolved by `StreamResolverService`, which never scopes by
|
||
source — had one. They still ask **per source scope**, one lookup per
|
||
distinct set of playlist-declared XMLTV URLs, and namespace the answers by
|
||
that scope: a `tvg-id` is unique inside a guide, not across imports, so a
|
||
single flat map keyed by lookup key alone would hand one playlist's card
|
||
the programme another playlist's guide resolved for the same id. Playlists
|
||
sharing a guide share one lookup. Only a card that carries a real XMLTV key
|
||
is widened: an Xtream or Stalker card has none, so its lookup key is just
|
||
its display title, and searching every guide by title would let a
|
||
same-named M3U channel answer for a portal channel. Those cards keep the
|
||
strict scope (their own programmes come from the portal), and the
|
||
any-source flag is part of the scope identity so the two never share an
|
||
answer. Wiring: `DashboardLiveEpgPresenter` in
|
||
`libs/workspace/dashboard/feature/src/lib/rails/`. The channel list keeps the strict scope. Single-channel current
|
||
program lookups include the source URL set in their cache and in-flight keys,
|
||
so playlist-local and global lookups deduplicate without reusing the wrong
|
||
source scope. Batch current-program lookups use the same source-scoped
|
||
per-channel TTL cache and order-insensitive in-flight batch deduplication
|
||
before reaching IPC; missing exact channel-id matches are resolved with batched
|
||
id/display-name candidate queries rather than a per-channel fallback loop.
|
||
Those candidate queries match the **raw key case-sensitively** as well as via
|
||
`LOWER()`: SQLite's `LOWER()`/`COLLATE NOCASE` only fold ASCII, so for non-ASCII
|
||
names (Cyrillic, Greek, …) a `LOWER()`-only match would miss channels whose
|
||
M3U name and EPG `display_name` share the same casing — the raw exact match
|
||
keeps parity with the timeline's single-channel exact-display-name lookup, and
|
||
the JS `resolveChannelMetadataCandidate` then folds with full-Unicode
|
||
`toLowerCase()`. The "airing now" window is compared with timezone-aware SQLite
|
||
`datetime()` on both sides (`EpgQueryService.isAiringAt`), not raw string
|
||
comparison: stored EPG timestamps often carry an offset (e.g. `+03:00`) while
|
||
`now` is built as UTC (`…Z`), so a lexical compare would be wrong by the offset
|
||
and surface a stale (or no) current programme. After the scoped + legacy
|
||
candidate queries, any candidate that resolved by id/display-name but still has
|
||
no in-scope current programme is retried once **unscoped** (all sources),
|
||
mirroring the timeline's own unscoped `getChannelPrograms` lookup. This keeps
|
||
the channel-list "now" line consistent with the timeline when a channel's row
|
||
and its programmes carry different `source_url` values (shared XMLTV ids across
|
||
multiple imports), where the channel resolves in scope but its programmes are
|
||
tagged with a source that is not currently enabled.
|
||
When upgrading an existing database whose historical programs have no
|
||
`source_url`, scoped program and metadata queries try those legacy unscoped
|
||
rows only after the requested source scope returns no result, so old EPG data
|
||
remains visible without taking precedence over freshly imported scoped data.
|
||
Channel metadata lookups use the same playlist-first, Settings-managed
|
||
fallback strategy so icons and display names can still come from global EPG
|
||
sources when the playlist-local guide only supplies programs. If multiple EPG
|
||
sources reuse the same XMLTV channel id, channel metadata and display-name
|
||
fallback lookups treat a channel as source-scoped when either the channel row
|
||
itself or matching programs are tagged with the requested `source_url`.
|
||
- The playlist details dialog shows enabled EPG URLs with explicit actions to
|
||
refresh, remove, or add a source to global Settings. It also allows adding one
|
||
or more manual playlist-local sources and indicates when additional detected
|
||
candidates were not auto-enabled. Removing a playlist-local source also
|
||
clears programs tagged with that `source_url` and prunes only orphaned channel
|
||
rows for that same source before saving the playlist metadata change, so a
|
||
failed cleanup keeps the source enabled and visible. Shared XMLTV channel ids
|
||
from other sources are preserved. Detected playlist sources are not silently
|
||
promoted to global settings.
|
||
|
||
### Performance Optimizations
|
||
|
||
| Optimization | Implementation |
|
||
| ----------------------------- | --------------------------------------------------------- |
|
||
| **Virtual Scroll** | CDK virtual scroll for 90,000+ channels |
|
||
| **Computed Signals** | `enrichedChannels` computed signal replaces template pipe |
|
||
| **Debounced Search** | 300ms debounce on search input |
|
||
| **Global Progress Tick** | Single 30s interval instead of per-item intervals |
|
||
| **OnPush Change Detection** | All components use OnPush |
|
||
| **Infinite Scroll in Groups** | IntersectionObserver loads 50 channels at a time |
|
||
| **Memoized Group Enrichment** | `enrichedGroupChannelsMap` computed signal |
|
||
|
||
### Tab Components
|
||
|
||
#### AllChannelsViewComponent
|
||
|
||
- **Inputs**: `channels`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `itemSize`, `activeChannelUrl`, `favoriteIds`
|
||
- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `favoriteToggled`, `sidebarToggleRequested`
|
||
- **Features**: Workspace search, persisted channel sorting, virtual scrolling, no-results placeholder
|
||
|
||
#### GroupsViewComponent
|
||
|
||
- **Inputs**: Same as AllChannelsViewComponent + `groupedChannels`
|
||
- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `favoriteToggled`, `hiddenGroupTitlesChanged`, sidebar sizing outputs
|
||
- **Features**: Resizable groups rail, local group search, group visibility management, persisted selected-group channel sorting
|
||
|
||
#### FavoritesViewComponent
|
||
|
||
- **Inputs**: `favorites`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
|
||
- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `favoriteToggled`, `favoritesReordered`
|
||
- **Features**: Drag-and-drop reordering with CDK DragDrop, read-only channel details context menu
|
||
|
||
#### RecentViewComponent
|
||
|
||
- **Inputs**: recent channels, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
|
||
- **Outputs**: `channelSelected`, `channelPlaybackRequested`, `removeRecent`
|
||
- **Features**: Read-only channel details context menu, row-level and context-menu removal
|
||
|
||
## EPG Integration
|
||
|
||
### Xtream channel-row programme refresh
|
||
|
||
The "current programme" line under each Xtream Live TV channel used to be
|
||
written only when a row scrolled into view, when an EPG result arrived, or
|
||
when the display offset changed. 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 (#767).
|
||
|
||
`PortalChannelsListComponent` re-checks the rows on screen once a minute. The
|
||
rules, each of which exists to avoid a specific failure:
|
||
|
||
- A programme still on air only has its **progress bar** advanced. No cache
|
||
read, no request: a row with nothing to learn must not cost traffic.
|
||
- Once it ends the row is re-picked from the queue's cache, and only a
|
||
programme that is **on air or upcoming** may replace it. A finished
|
||
programme is never re-applied — it would keep presenting itself as current,
|
||
and the earliest-item fallback that fills a blank row on first paint would
|
||
move an advanced row *backwards*.
|
||
- A cached guide whose programmes have **all** ended is dropped
|
||
(`EpgQueueService.invalidate`) and refetched, because the queue skips any
|
||
stream that still holds a cached answer. An **empty** answer means the
|
||
provider has no guide for that channel and is left alone; re-asking would
|
||
put one call per EPG-less visible row on the wire every minute.
|
||
- What is on screen stays there until a replacement arrives, so a refreshing
|
||
row never blanks out.
|
||
|
||
A programme occupies `[start, stop)` in every one of these comparisons, so
|
||
"has it ended" and "what is on air" cannot disagree on the boundary instant.
|
||
The selection rules are pure functions in
|
||
`libs/portal/xtream/feature/src/lib/portal-channels-list/epg-preview-program.ts`
|
||
and take an explicit `nowMs` in the PROVIDER's clock (`epgProviderClockMs`),
|
||
never `Date.now()`.
|
||
|
||
Two root-provided services exist because a live layout mounts the channel list
|
||
more than once — the sidebar and the fullscreen channel panel render side by
|
||
side — over one shared `EpgQueueService`:
|
||
|
||
- `EpgRefillLimiter` is the floor on dropping an exhausted cache, the one
|
||
place that overrides the queue's own throttling. A provider whose guide has
|
||
genuinely run out answers the refill with the same finished programmes, so
|
||
without a floor the row would ask again on the very next tick. Records carry
|
||
the owning playlist, since a stream id is provider-local and the service
|
||
outlives a playlist switch, and they expire by age rather than by viewport
|
||
membership — a claim dropped when its row scrolled away would be handed back
|
||
the moment the user scrolled to it again.
|
||
- `EpgRefreshCoordinator` owns the single timer and merges what every mounted
|
||
list needs into one queue request. `EpgQueueService.enqueue` is latest-wins:
|
||
it bumps one generation, replaces the queue and the visible set, and drops an
|
||
earlier caller's entries after its XMLTV await. Separate timers would cancel
|
||
each other whenever both lists had rows to fill, which is exactly what
|
||
happens on a programme boundary. Each list still decides for itself what is
|
||
stale (that reads only its own state) and contributes its whole visible
|
||
slice, since the queue drops anything outside the visible set it was last
|
||
handed; a channel both lists show is fetched once, and playlists stay apart
|
||
because their credentials differ.
|
||
|
||
### XMLTV response compression
|
||
|
||
The Electron EPG worker decodes HTTP `Content-Encoding` layers in reverse
|
||
order before parsing XMLTV. The existing URL (including redirects), MIME type
|
||
and filename checks identify gzip files independently of HTTP compression.
|
||
When both a gzip file hint and an HTTP gzip layer are present, a streaming
|
||
stage checks the decoded body's first two bytes (`1F 8B`). A remaining gzip
|
||
signature triggers exactly one additional file decompression; otherwise the
|
||
body passes through unchanged. This supports both genuinely double-compressed
|
||
feeds and providers that describe a single gzip layer with both metadata forms.
|
||
Other decoding paths retain their existing behavior.
|
||
|
||
The probe preserves bytes across chunk boundaries and keeps bounded buffering
|
||
and stream backpressure. Invalid or truncated gzip fails the import; it is not
|
||
retried as plain XML. Source errors and consumer cancellation terminate the
|
||
whole decoding chain. The decoder does not recursively unpack file layers or
|
||
change XML parsing, source reconciliation, or database persistence contracts.
|
||
|
||
### Local XMLTV files
|
||
|
||
An EPG source is not necessarily a URL. Settings → EPG and the playlist
|
||
dialog accept, next to `http(s)` links, a file on the user's computer in any
|
||
of these shapes (Electron only — the PWA has no EPG import at all):
|
||
|
||
- a `file:` URL (`file:///home/you/epg/guide.xml.gz`, `file:///C:/epg/guide.xml`)
|
||
- an absolute POSIX path (`/home/you/epg/guide.xml`)
|
||
- a Windows drive or UNC path (`C:\epg\guide.xml.gz`, `\\nas\share\guide.xml`)
|
||
|
||
Both surfaces also offer a folder button that opens the native file picker
|
||
(`EPG_OPEN_FILE_DIALOG` → `ElectronBridgeApi.openEpgFileDialog`, gated on
|
||
`RuntimeCapabilitiesService.supportsEpgFilePicker`) and writes the chosen
|
||
absolute path into the row. Relative paths are refused: the main process
|
||
has no meaningful working directory to resolve them against. The value is stored exactly as typed
|
||
(trimmed) and is the source key everywhere — freshness, reconciliation, the
|
||
progress panel and `epg_channel_sources` all treat it like a URL string.
|
||
|
||
The shape rules live in one place, `classifyEpgSourceReference()` in
|
||
`libs/shared/interfaces/src/lib/epg-source-reference.util.ts`, and both
|
||
forms validate through its structural `validateEpgSourceReferenceControl`.
|
||
In the worker, `openEpgSourceStream()` (`epg-source-stream.ts`) is the single
|
||
entry that yields decoded XMLTV bytes: remote sources still go through
|
||
`requestWithValidatedRedirects` with the private-network/TLS trust policy,
|
||
while local sources are read with `fs.createReadStream` behind the same
|
||
signature-sniffing optional gunzip stage the HTTP path uses, so `.xml`,
|
||
`.xml.gz` and an extension-less gzip file all parse. A missing file, a
|
||
directory, or a truncated gzip fail the import with a plain message; nothing
|
||
is retried as XML.
|
||
|
||
**Provenance rule.** Only values the user chose or typed by hand may be
|
||
local, and the decision is enforced in the main process, not in the form.
|
||
The local branch bypasses `validateRemoteUrl` (which only knows http/https),
|
||
so two layers guard it:
|
||
|
||
- Header-declared M3U sources (`x-tvg-url`/`url-tvg`/`tvg-url`) are filtered
|
||
to remote links — `extractM3uEpgUrls` no longer matches `file:`, and
|
||
`resolvePlaylistEpgSourceState` / `filterPlaylistEpgUrlsForFetch` drop a
|
||
legacy non-remote entry that older versions stored from a header unless it
|
||
also appears in `manualEpgUrls`. A downloaded playlist cannot point the
|
||
importer at a file on disk; a user whose own local M3U references a local
|
||
XMLTV adds that file in the playlist dialog instead.
|
||
- `FETCH_EPG`/`EPG_FORCE_FETCH` carry renderer-supplied strings, so
|
||
`EpgWorkerService.startFetch` asks the main-process
|
||
`EpgLocalSourceAuthorizer` (`epg-local-source-authorizer.ts`) before a
|
||
local path reaches the worker. A path the native picker returned is trusted
|
||
at once; a hand-typed path is confirmed once in a native message box the
|
||
renderer cannot fake, and a refusal fails that fetch — the progress panel
|
||
shows it with Retry ("Retry to be asked again") and the IPC result does
|
||
not report success. Allowed paths persist under
|
||
`TRUSTED_LOCAL_EPG_SOURCES` in the main-process config (`store.service.ts`)
|
||
so startup refreshes need no prompt. The worker's local branch opens only
|
||
when main set `allowLocalFile` on the fetch options — the flag is never
|
||
taken from the renderer — and the service defaults to a deny-all authorizer
|
||
until `epg.events.ts` installs the persisted one, so a missing wiring fails
|
||
closed. This mirrors `save-file-dialog` → `write-file` for playlist exports.
|
||
|
||
### EpgService (`@iptvnator/epg/data-access`)
|
||
|
||
```typescript
|
||
class EpgService {
|
||
// Fetch EPG for multiple URLs
|
||
fetchEpg(urls: string[]): void;
|
||
|
||
// Get programs for a channel
|
||
getChannelPrograms(channelId: string): void;
|
||
|
||
// Batch fetch current programs
|
||
getCurrentProgramsForChannels(
|
||
channelIds: string[],
|
||
options?: { sourceUrls?: string[] }
|
||
): Observable<Map<string, EpgProgram>>;
|
||
|
||
// Batch fetch XMLTV channel metadata for logo fallback
|
||
getChannelMetadataForChannels(
|
||
channelIds: string[],
|
||
options?: { sourceUrls?: string[] }
|
||
): Observable<Map<string, EpgChannelMetadata | null>>;
|
||
|
||
// Observables
|
||
epgAvailable$: Observable<boolean>;
|
||
currentEpgPrograms$: Observable<EpgProgram[]>;
|
||
}
|
||
```
|
||
|
||
### EPG Components
|
||
|
||
| Component | Purpose |
|
||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `EpgTimelineComponent` | Horizontal timeline for one channel |
|
||
| `EpgListViewComponent` | Vertical single-day list alternative |
|
||
| `EpgItemDescriptionComponent` | Program details dialog |
|
||
| `EpgGuideComponent` | Multi-channel programme guide grid fed by `EPG_GUIDE_SOURCE` (rows: `EpgGuideRowComponent`, toolbar: `EpgGuideToolbarComponent`, docked strip: `EpgGuideNowPlayingComponent`) |
|
||
|
||
## Video Player
|
||
|
||
**Location**: `libs/playlist/m3u/feature-player/src/lib/video-player/`
|
||
|
||
### Supported Players
|
||
|
||
- **ArtPlayer** (default) - Modern player with plugins
|
||
- **Video.js** - Fallback with HLS support
|
||
- **HTML5** - Basic video element
|
||
- **Audio** - For radio streams
|
||
|
||
### Player Features
|
||
|
||
- Channel navigation (prev/next)
|
||
- Favorites toggle
|
||
- EPG sidebar
|
||
- Collapsible inline EPG panel for internal players, persisted through the
|
||
shared `live-epg-panel-state` preference
|
||
- Programme guide (multi-channel grid) in guide mode — see "Programme guide" below
|
||
- Channel info overlay
|
||
- External player support (MPV, VLC) in Electron
|
||
- M3U archive/catch-up playback for supported replay schemes
|
||
|
||
### Archive / Catch-Up Playback
|
||
|
||
- The shared EPG UI only shows the archive replay badge when the host confirms
|
||
that the selected M3U channel has a playable replay scheme. Archive days
|
||
alone are not enough.
|
||
- M3U catch-up support is resolved in `@iptvnator/shared/m3u-utils` from channel metadata and
|
||
the archived program start time.
|
||
- Supported replay precedence:
|
||
1. `catchup.source` if it is an HTTP(S) URL. IPTVNator rewrites or appends
|
||
standard `utc` and `lutc` query params on that URL.
|
||
2. Legacy same-stream shift playback when `catchup.type === 'shift'`. In
|
||
that case IPTVNator rewrites or appends `utc` and `lutc` on `channel.url`.
|
||
3. Legacy same-stream shift fallback when no explicit catch-up mode is
|
||
declared, archive-day metadata exists (`tvg.rec`, `timeshift`, or
|
||
`catchup.days`), and `channel.url` itself is an HTTP(S) stream URL. This
|
||
covers providers that only advertise archive retention such as
|
||
`tvg-rec="7"` but still expect standard `utc` and `lutc` query params on
|
||
the live URL.
|
||
- `tvg.rec`, `timeshift`, and `catchup.days` still define the archive window
|
||
shown in the EPG, but replay remains unavailable when the provider declares a
|
||
different explicit catch-up scheme that IPTVNator does not understand or when
|
||
the stream URL itself is not an HTTP(S) replay target.
|
||
- Active replay is stored separately from the selected channel in
|
||
`playlistState.activePlaybackUrl`. Inline and external players use
|
||
`activePlaybackUrl ?? activeChannel.url`, and returning to live playback
|
||
clears the override.
|
||
- The unified favorites/recent live tab
|
||
(`libs/portal/shared/ui/.../unified-collection/unified-live-tab.component.ts`)
|
||
hosts the same timeline but does not use the NgRx playlist state; it keeps
|
||
its own `activeTimeshift` signal and hands it to
|
||
`createUnifiedLiveCatchup` (`unified-live-catchup.ts`), which resolves the
|
||
replay URL with `resolveM3uCatchupUrl` and swaps the inline player's
|
||
playback target (or hands the URL to the configured external player).
|
||
Selecting another channel,
|
||
closing the player, or "Return to live" clears the override.
|
||
- Catch-up activation is never silent: if the replay URL cannot be resolved
|
||
for a programme the user clicked, both hosts surface a
|
||
`EPG.TIMELINE.CATCHUP_FAILED` snackbar instead of doing nothing.
|
||
|
||
### Copy archive URL
|
||
|
||
Programme details opened from the EPG timeline or list offer **Copy archive
|
||
URL** when catch-up is available. Xtream Live TV, M3U playback and their
|
||
Favorites/Recent live tabs resolve the same URL as archive playback without
|
||
selecting a programme or changing the player. Stalker and unsupported/expired
|
||
archive entries expose no copy action. The full M3U guide currently has no
|
||
catch-up capability and therefore does not expose this action.
|
||
|
||
The URL can contain account credentials; the dialog explains that external
|
||
players may also need the source's HTTP headers. Copy is explicit, errors use
|
||
localized feedback without raw URLs, and each explicit request supersedes older
|
||
unresolved copies. Only the latest request may update the clipboard or feedback,
|
||
so a slow resolver cannot block another channel or overwrite its copied URL. Timeline/list dialogs capture the host's source/channel
|
||
`archiveContextKey`, refuse actions after it changes, and unsubscribe when
|
||
the view is destroyed. A copied M3U start-over URL need not stop at the programme's
|
||
end. Copying a URL does not download or preserve an expiring archive.
|
||
|
||
### Programme guide
|
||
|
||
`app-epg-guide` (`libs/ui/epg/src/lib/epg-guide/`) is a host-agnostic
|
||
multi-channel grid. It reads everything through the `EPG_GUIDE_SOURCE`
|
||
injection token (`epg-guide-source.ts`): the scope-resolved channel list
|
||
(`EpgGuideChannel { id, number, name, logoUrl, epgKey }`), the available scopes
|
||
(all / group / favorites), `loadPrograms(window)` and `loadCoverage(window)`
|
||
for a provider-clock time window, the active channel and `activate(id)`. An
|
||
optional `searchPrograms(query)` returns `EpgGuideSearchHit { channelId,
|
||
program }`, where `channelId` is `null` when the host cannot say which row a
|
||
hit belongs to. The M3U adapter is `M3uEpgGuideSourceService`
|
||
(`libs/playlist/m3u/feature-player/src/lib/epg-guide/`): channels come from
|
||
NgRx, `epgKey` uses the `tvg.id → tvg.name → name` chain, and the two bridge
|
||
reads `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` resolve keys
|
||
in the main process (manual mappings first, then the metadata lookup shared
|
||
with the sidebar) and return programmes keyed by the requested key. Queries
|
||
are unscoped, like the timeline.
|
||
|
||
Row ids are not channel ids: `createChannel` falls back to the stream URL for
|
||
an entry without an explicit id, so one stream listed in two groups yields two
|
||
channels sharing an id. The adapter therefore builds SCOPE-LOCAL row ids by
|
||
prefixing every row with its position in the scope (`<index>:<channel id>`) —
|
||
the guide keys its programme, coverage and selection maps by row id, so
|
||
without that both copies lit up as playing and activating either one played
|
||
the first, and suffixing only the repeats was not enough because a real
|
||
channel id can itself look like a generated suffix (ids `x`, `x`, `x#1`
|
||
produced `x#1` twice). Ids move with the scope and the channel list, so only
|
||
ids the guide was just handed may be passed back. The active channel is
|
||
resolved to a row in three steps, because the store spreads the selected
|
||
channel (identity is gone) and copies can share an id: the row whose channel
|
||
matches the playing one field for field (every field except the
|
||
reducer-rewritten `epgParams`, key order ignored — `isSameChannelEntry`),
|
||
else the first same-id row with the same stream url, else the first same-id
|
||
row; `null` when the channel is outside the scope. `activate(rowId)`
|
||
resolves the row back to its own channel object. Opening the
|
||
guide mirrors the sidebar view (`applyInitialScope`): favorites stays
|
||
favorites, and the groups view opens on the group the sidebar's rail is
|
||
SHOWING — forwarded from `GroupsViewComponent.selectedGroupChange` through the
|
||
channel list container and `app-sidebar` into
|
||
`VideoPlayerComponent.selectedSidebarGroup` — because the user may have
|
||
browsed away from the playing channel's group before opening the guide. The
|
||
playing channel's group is the fallback, then `all`.
|
||
|
||
Guide mode is host layout, not an overlay: `VideoPlayerComponent.guideOpen`
|
||
hides the sidebar and the timeline, renders the guide, and CSS reflows the
|
||
untouched `app-web-player-view` into a 128 px docked strip
|
||
(`.content-container.is-guide`) beside `app-epg-guide-now-playing`; the strip
|
||
collapses to one 48 px line (`epg-guide:dock-collapsed`), which is also the
|
||
fixed height of the strip shown for an external MPV/VLC session — that one has
|
||
no video to reveal, so it hides the Collapse toggle (`collapsible=false`) and
|
||
writes no preference. Nothing remounts, so playback and native-view Embedded
|
||
MPV bounds survive. While the guide is open the docked `.video-player` carries
|
||
`data-player-shortcuts-suspended`, which makes `ControlsShortcuts` (shared and
|
||
legacy player shortcuts alike) yield ↑/↓, Space, F and M to the guide's own
|
||
keyboard controller. Entry points: the workspace header action
|
||
(`m3u-epg-guide`), the command palette, the Guide button in the EPG panel's
|
||
toolbar (`openGuide` on both `EpgTimelineComponent` and `EpgListViewComponent`,
|
||
so the action survives the list-view setting) and the `G` key on the player
|
||
page.
|
||
The header action reports `disabled` whenever the guide cannot open, which
|
||
greys out the header button and disables its palette command instead of
|
||
offering a no-op. Player fullscreen, radio, recognised movies, switching to
|
||
another playlist and the PWA close or withhold it. `openGuide()` itself also
|
||
refuses while ANY element is fullscreen or a CDK dialog is open — the guide
|
||
mounts in the page flow, so it would be painted over and still swallow the
|
||
keyboard — and the `G` key additionally ignores a press raised inside a
|
||
`.cdk-overlay-pane`, `[role="menu"]` or `[role="dialog"]`, the same surfaces
|
||
the PageUp/PageDown zapping yields to. Inside the guide: single click on a row or an
|
||
"on now" card switches the channel and keeps the guide open, double-click or
|
||
Enter switches and closes, other cards open the programme dialog; ↑/↓ ←/→
|
||
navigate, I details, N now, PgUp/PgDn day, Esc close — the keyboard controller
|
||
ignores keys on any element matching its interactive-target selector
|
||
(inputs, buttons, menu/option items, links, editable content) unless that
|
||
element carries `data-epg-guide-grid`, which the guide's own channel cells and
|
||
programme cards set so their `role="button"` does not shadow the grid's own
|
||
keys; a real control nested inside one (the catch-up button) still wins.
|
||
Density (`epg-guide:density`, comfortable 60 px / compact 44 px), zoom
|
||
(`epg-guide:zoom`, 120–480 px per hour, default 240) and the "Only with EPG"
|
||
toggle (`epg-guide:only-with-epg`) persist in localStorage; coverage is loaded
|
||
for the whole scope so the toggle never hides rows as they scroll in. The
|
||
guide reads the EPG display offset itself: the day axis is display time, the
|
||
request window is converted with `epgProviderClockMs`.
|
||
|
||
**Backend contract**: `EpgGuideQueryService`
|
||
(`apps/electron-backend/src/app/events/epg-guide-query.service.ts`) answers
|
||
both IPCs from one `guideWindowCondition()` predicate — when the request
|
||
carries `sourceUrls` (portal hosts only; the M3U host never does), a row
|
||
qualifies if it belongs to one of those sources OR carries no source at all
|
||
(legacy pre-per-source-tracking data), never if it belongs to a _different_
|
||
source. Both reads cap the requested channel-key batch
|
||
(`EPG_GUIDE_MAX_CHANNELS_PER_REQUEST` = 100 for programmes,
|
||
`EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST` = 2000 for the cheaper coverage
|
||
probe) and respond with exactly the trimmed, de-duplicated, cap-respecting
|
||
requested keys: a key cut by the cap is absent from the answer, never present
|
||
with an empty list, so a caller can tell "queried, nothing found" apart from
|
||
"not queried at all". An invalid window (bad instants, `fromMs >= toMs`, no
|
||
usable keys) returns `{}`/`[]` rather than throwing.
|
||
|
||
### External Player Request Headers
|
||
|
||
The built-in web players inherit the playlist-level custom
|
||
User-Agent/Referer through the Electron `webRequest` header override
|
||
(`set-user-agent` IPC: the playlist values form the session-wide override, the
|
||
active channel's `#EXTVLCOPT` values a stream-scoped one on top). External
|
||
MPV/VLC and the embedded MPV player make their own HTTP requests, so those
|
||
launches carry the headers in the payload instead:
|
||
`resolveExternalPlayerHttpHeaders()` in
|
||
`libs/m3u-state/src/lib/external-player-payload.util.ts` resolves each of
|
||
User-Agent/Referer/Origin independently — the channel's `#EXTVLCOPT` value
|
||
wins, the playlist-level value (import dialog / playlist settings) is the
|
||
fallback, and blank values count as absent. Every M3U external launch path
|
||
goes through it: the auto-launch and catch-up effects in `m3u-state`, the
|
||
manual MPV/VLC fallback in `VideoPlayerComponent`, and the embedded MPV
|
||
payload (`embeddedPlayback`). In the main process both players emit the same
|
||
header field list (`buildHttpHeaderFields`): a real `Origin: ...` header
|
||
(deduplicated against the custom headers map) plus every non-empty custom
|
||
header — MPV via `--http-header-fields`, VLC via per-input `:http-header=`
|
||
options in both the fresh-spawn and RC-enqueue paths. VLC additionally keeps
|
||
its legacy origin-as-Referer fallback when no Referer is set.
|
||
|
||
### DASH + ClearKey Playback
|
||
|
||
MPEG-DASH (`.mpd`) channels play through a Shaka Player _source engine_ inside
|
||
the existing built-in players — exactly like hls.js/mpegts.js. There is no new
|
||
player in settings.
|
||
|
||
**DRM data flow** (M3U module only; Xtream/Stalker have no DRM concept):
|
||
|
||
1. The playlist parser fork does not interpret `#KODIPROP:` lines, but
|
||
preserves them in `item.raw` for **both** layouts: unknown lines between
|
||
`#EXTINF` and the stream URL are kept as before, and since parser pin
|
||
`v0.15.2-iptvnator.2` `#KODIPROP` lines placed _before_ the `#EXTINF` are
|
||
buffered and attached to the **next** entry's `raw` in file order (Kodi
|
||
semantics, case-insensitive prefix). Other stray `#` lines outside an open
|
||
item are still dropped, matching upstream.
|
||
2. `extractDrmFromRaw()` (`libs/shared/m3u-utils/src/lib/kodiprop.utils.ts`)
|
||
post-processes `raw` inside `createPlaylistObject()` — the single funnel
|
||
for all four import paths (Electron URL/file import, refresh worker,
|
||
web-backend `/parse`, client-side upload). It reads
|
||
`inputstream.adaptive.license_type`, `license_key`, and the combined
|
||
`drm_legacy` property. ClearKey key formats: `kid:key` hex (single or
|
||
comma-separated), the W3C ClearKey license JSON, and a plain `{kid: key}`
|
||
JSON map. Key components also accept Base64URL and ordinary Base64 (`+`/`/`),
|
||
with or without padding; decoded keys and KIDs must remain exactly 128 bits.
|
||
This includes JSON exports that use ordinary Base64 instead of the W3C
|
||
Base64URL alphabet. Refresh an already imported source to replace a cached
|
||
`drm.supported: false` result after a parser compatibility update.
|
||
Unsupported license types (Widevine, PlayReady, license-server
|
||
URLs, malformed values) are preserved as `supported: false` — never a
|
||
throw.
|
||
3. The typed result lands on `Channel.drm` (`ChannelDrm` in
|
||
`@iptvnator/shared/interfaces`), travels through
|
||
`ResolvedPortalPlayback.drm` into `WebPlayerViewComponent`'s synthetic
|
||
channel, and reaches the engine. Persistence is free for newly imported or
|
||
refreshed playlists (playlist JSON blob / IndexedDB object). Playlists
|
||
imported **before** the DRM feature carry no `drm` field yet, but the raw
|
||
`#KODIPROP` block survived in the stored items — the M3U player page and
|
||
collection `StreamResolverService.buildM3uPlayback()` fall
|
||
back to `extractDrmFromRaw(channel.raw)` at playback time, so encrypted
|
||
channels of pre-upgrade playlists work without a re-import. Favorites and
|
||
Recently Viewed (playlist and global scopes) pass the same DRM metadata
|
||
through `ResolvedPortalPlayback.drm`; an explicit stored DRM result,
|
||
including `supported: false`, takes precedence over the raw fallback.
|
||
|
||
`UnifiedLiveTabComponent` applies the same M3U DASH routing to favorites and
|
||
recent selections: ArtPlayer keeps its Shaka engine; all other preferences
|
||
use HTML5/Shaka inline. This also applies to a resolved DASH catch-up URL and
|
||
does not change the saved player preference. Clear DASH needs this routing too.
|
||
|
||
**Engine selection and routing:**
|
||
|
||
- `ShakaVideoSession` (`libs/ui/playback/src/lib/shaka-engine/`) owns the
|
||
engine: lazy `import('shaka-player')` on first use (the module is a separate
|
||
lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation
|
||
queue + generation guard against channel-switch races. The DOM-free Shaka
|
||
`5.2.4` public-error boundary lives in `libs/playback/util`; it version-locks
|
||
its allowlisted
|
||
severity/category/code values, emits only structured sanitized
|
||
`PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events,
|
||
and treats a rejected load as terminal even if its final retry error retains
|
||
recoverable severity. Raw messages and `error.data` never cross the boundary;
|
||
only documented direct/nested `BAD_HTTP_STATUS` slots contribute HTTP
|
||
status, and direct network request-type slots identify manifest, segment or
|
||
license stages. A session-owned response observer adds safe advertised codec
|
||
and DRM names from the already fetched DASH manifest; it never fetches extra
|
||
data or retains license URLs, keys or the manifest body. Exact category/code pairs classify failures, while an
|
||
ambiguous Manifest category or unknown pair remains an unknown diagnostic.
|
||
The public streaming-startup code `5006` is retained exactly but keeps
|
||
unknown stage/failure. Public DASH text-parser codes likewise retain their
|
||
exact `TEXT` category/code but keep stage/failure unknown. A failed
|
||
`Player.isBrowserSupported()` preflight also stays unknown rather than
|
||
claiming container incompatibility. It carries only the enumerated,
|
||
app-owned `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker — never a
|
||
producer recommendation hint or engine payload. Clear DASH still offers
|
||
configured external-player actions, while KODIPROP DRM keeps them disabled
|
||
because those players never receive its keys.
|
||
Channels with `drm.supported === false` emit a `DrmOrEncryption` diagnostic
|
||
with a fixed safe detail string and without starting an engine.
|
||
- HTML5 player: the `dash` branch of `playChannel()` (source kind from the
|
||
shared `resolvePlaybackUrlSourceKind()`). ArtPlayer: `customType.mpd` in
|
||
`ArtPlayerSourceSession`. Shared-controls bridge:
|
||
`WebVideoControlsSource` kind `'shaka'` + `WebVideoShakaControls`
|
||
(audio/text tracks via the Shaka 5 API — selecting a text track shows it,
|
||
`selectTextTrack(null)` hides).
|
||
- DASH channels always play inline (radio precedent): `isDashChannel()` gates
|
||
`shouldShowInlinePlayer()`, the MPV/VLC auto-launch in `m3u-state` effects
|
||
(`shouldAutoLaunchExternalPlayer()`), and the `playerOverride` passed to
|
||
`app-web-player-view` — ArtPlayer stays ArtPlayer, every other configured
|
||
player (Video.js without a DASH bridge, embedded/external MPV, VLC) falls
|
||
back to the HTML5 player. External players cannot receive KODIPROP ClearKey
|
||
configuration (VLC upstream feature request #29465).
|
||
- ClearKey EME works in stock Electron (`org.w3.clearkey`; no Widevine CDM
|
||
required) — EME needs a secure context, which `file://` (packaged) and
|
||
`http://localhost` (dev/PWA) both satisfy. Widevine/FairPlay are out of
|
||
scope (castLabs fork + VMP signing).
|
||
|
||
**Testing:** offline VP9+Opus CENC fixtures in
|
||
`apps/web-e2e/src/fixtures/dash/` (generated with ffmpeg + Shaka Packager —
|
||
see the README there for why), e2e suites `web-e2e:src/dash-clearkey.e2e.ts`
|
||
(Chromium; the Angular service worker is blocked because SW-routed requests
|
||
bypass Playwright interception) and
|
||
`electron-backend-e2e:src/dash-clearkey.e2e.ts` (real ClearKey EME in
|
||
Electron).
|
||
|
||
## M3U Playback Mode
|
||
|
||
`isLikelyM3uVod()` in `libs/shared/m3u-utils` determines playback mode
|
||
independently of TMDB, `Settings.m3uVodDetails`, and the entry's name.
|
||
Video-file extensions (`mp4`, `mkv`, `webm`, and the existing movie-container
|
||
list) or exact `/movie/`, `/movies/`, `/vod/`, `/series/` pathname segments
|
||
identify VOD. Extension parsing honors the existing query-declared formats.
|
||
Episodes receive VOD controls even when movie metadata recognition excludes
|
||
their names. A group label or episode name alone is not playback evidence.
|
||
Radio and DASH retain their existing paths; ordinary HLS/TS and unknown URLs
|
||
without a VOD path retain live mode. No network probing is performed.
|
||
|
||
`VideoPlayerComponent.embeddedPlayback()` is the mode owner for both the
|
||
ordinary M3U player and the movie detail host: a catch-up URL or positive VOD
|
||
evidence sets `isLive: false`. HTML5, Video.js, ArtPlayer and Embedded MPV
|
||
consume that existing flag without provider-specific changes. Actual seeking
|
||
still requires a usable duration and a seekable source. External MPV/VLC
|
||
launch payloads, session identity, headers, DRM and radio playback are unchanged.
|
||
Xtream and Stalker continue to determine their own playback modes.
|
||
|
||
Coverage includes the detection utility and M3U host specs, plus real seek
|
||
controls in `apps/web-e2e/src/m3u-movie-details.e2e.ts` using the local WebM
|
||
fixture with HTTP Range responses. The E2E matrix covers all three web players,
|
||
TMDB off, movie details off, details on, episodes and live/VOD transitions.
|
||
|
||
## Movie Recognition (VOD Detail View)
|
||
|
||
M3U playlists routinely mix live channels with movie FILES ("Movies"/"VOD"
|
||
groups, Xtream-derived exports). A recognized movie swaps the player + EPG
|
||
zone for the same two-state VOD detail shell the portals use, fed by TMDB
|
||
metadata. Works in both Electron and the PWA.
|
||
|
||
**Gate** (`VideoPlayerComponent.showMovieDetail`): the branch activates only
|
||
when ALL hold — the URL-shape heuristic recognizes the channel, TMDB
|
||
enrichment is enabled (`TmdbEnrichmentService.isEnabled()`), and
|
||
`Settings.m3uVodDetails` is not `false` (default on; the checkbox lives in
|
||
Settings → Metadata (TMDB) under the master toggle). The decision is
|
||
**synchronous** — layout is chosen at activation; the async TMDB lookup only
|
||
patches metadata into an already-mounted view, so a missed match never causes
|
||
a layout jump.
|
||
|
||
**Detection** (`isLikelyM3uMovie` in
|
||
`libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.ts`): a movie-file
|
||
container extension (`mkv`, `mp4`, `avi`, … — deliberately NOT `ts`/`m3u8`/
|
||
`m4s` which deliver live streams, and not `mpd` which has its own DASH path)
|
||
OR an Xtream-style `/movie/`, `/movies/`, `/vod/` path segment. Exclusions
|
||
fail toward today's live layout: `radio="true"`, DASH URLs, `/series/` paths,
|
||
and names carrying an episode marker (`S01E02`, `1x02`, "2 серия",
|
||
"Season 3" — the season word list is shared with the title normalizer).
|
||
Known accepted cost: "Star Wars: Episode 1" is skipped.
|
||
|
||
**Flow** (`m3u-vod-detail/` in `libs/playlist/m3u/feature-player`):
|
||
|
||
- `M3uVodDetailComponent` hosts `PortalDetailShellComponent` +
|
||
`PortalInlinePlayerComponent`. **Watch-first**: clicking a channel keeps
|
||
M3U zapping semantics — playback starts immediately, the About block
|
||
renders below where the EPG zone would be; Escape/close reveals the Browse
|
||
hero, Play re-enters watch. The hero back arrow is hidden (the channel
|
||
sidebar next to the detail IS the navigation).
|
||
- The parent passes its already-built `embeddedPlayback()`
|
||
(`ResolvedPortalPlayback` with headers/EXTVLCOPT resolved) plus its shared
|
||
persisted `volume()`; the host forwards the same payload, whose `isLive`
|
||
is already determined independently of metadata by the M3U parent. The
|
||
payload's OBJECT IDENTITY is the player's
|
||
source-application key (`createWebPlayerApplicationState` mints a new
|
||
source revision for any new payload), so it must never depend on TMDB
|
||
signals — folding the resolved title/poster in there restarted the movie
|
||
the moment enrichment landed. Metadata lives in the hero/About
|
||
presentation only.
|
||
- `PortalInlinePlayerComponent` gained an optional `volume` input
|
||
(default `1`) for this host: the M3U player owns a persisted volume shared
|
||
across its channels, while the portals keep the engines' own default. The
|
||
parent re-reads that bus per channel AND on the host's `playbackStarted`
|
||
output, because Browse → Play remounts the engine without a channel change
|
||
and the engines only ever write to the bus.
|
||
|
||
**Coverage:** `apps/web-e2e/src/m3u-movie-details.e2e.ts` drives the workflow
|
||
against the real composition with a routed TMDB mock — recognition, async
|
||
metadata, the live-channel fallback, and the volume the remount must keep.
|
||
|
||
- `M3uVodMetadataService` (component-provided) calls
|
||
`TmdbEnrichmentService.enrichMovie({ title: channel.name, year:
|
||
releaseTagYear(channel.name) })` — the resolver already normalizes titles
|
||
and caches verdicts; the service only adds the release-year hint and a
|
||
staleness guard keyed on `channel.id` so zapping mid-request drops the
|
||
stale resolution.
|
||
- No TMDB match keeps the thin provider presentation (entry name + logo) in
|
||
the detail layout rather than jumping back to the EPG view.
|
||
- External MPV/VLC users keep Browse: the m3u-state effects launch the
|
||
external player on activation exactly as before, and
|
||
`inlinePlayerAvailable=false` means the host never mounts an inline player.
|
||
|
||
**Out of scope (v1)**: series episode metadata (movie detection skips them;
|
||
episode playback still receives VOD controls when its URL qualifies), playback
|
||
position persistence for M3U movies, clickable cast chips (no
|
||
`actor/:personId` route in the M3U tree), and downloads.
|
||
|
||
## Interfaces
|
||
|
||
### Channel Interface
|
||
|
||
```typescript
|
||
interface Channel {
|
||
id: string;
|
||
url: string;
|
||
name: string;
|
||
group: { title: string };
|
||
tvg: {
|
||
id: string; // For EPG matching
|
||
name: string;
|
||
url: string;
|
||
logo: string;
|
||
rec: string;
|
||
};
|
||
epgParams?: string;
|
||
timeshift?: string;
|
||
catchup?: { type?: string; source?: string; days?: string };
|
||
radio: string;
|
||
http: {
|
||
referrer: string;
|
||
'user-agent': string;
|
||
origin: string;
|
||
};
|
||
/** ClearKey DRM extracted from #KODIPROP lines (DASH channels). */
|
||
drm?: ChannelDrm;
|
||
}
|
||
```
|
||
|
||
### Playlist State Additions
|
||
|
||
```typescript
|
||
interface PlaylistState {
|
||
active: Channel | undefined;
|
||
activePlaybackUrl: string | null;
|
||
currentEpgProgram: EpgProgram | undefined;
|
||
epgAvailable: boolean;
|
||
channels: Channel[];
|
||
}
|
||
```
|
||
|
||
### EpgProgram Interface
|
||
|
||
```typescript
|
||
interface EpgProgram {
|
||
start: string; // ISO string
|
||
stop: string; // ISO string
|
||
channel: string; // TVG ID
|
||
title: string;
|
||
desc: string | null;
|
||
category: string | null;
|
||
episodeNum?: string | null;
|
||
iconUrl?: string | null;
|
||
rating?: string | null;
|
||
}
|
||
```
|
||
|
||
## Routes
|
||
|
||
Routes live in `libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts`
|
||
(`createM3uWorkspaceRoutes()`), nested under the workspace shell:
|
||
|
||
```
|
||
/workspace/playlists/:id # M3U player (redirects to .../all)
|
||
/workspace/playlists/:id/favorites # Favorites collection view
|
||
/workspace/playlists/:id/recent # Recently viewed collection view
|
||
/workspace/playlists/:id/:view # Video player with channel list view
|
||
```
|
||
|
||
## Adding New Features
|
||
|
||
### To add a new view to channel list:
|
||
|
||
1. Create component in `channel-list-container/new-view/`
|
||
2. Accept inputs: `channels`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
|
||
3. Emit `channelSelected` output
|
||
4. Add to parent template and imports
|
||
|
||
### To add EPG-related features:
|
||
|
||
1. Use `EpgService` for data fetching
|
||
2. Subscribe to `channelEpgMap` signal for current programs
|
||
3. Dispatch `EpgActions` for state updates
|
||
|
||
### To modify favorites behavior:
|
||
|
||
1. Dispatch `FavoritesActions.updateFavorites` for toggle
|
||
2. Dispatch `FavoritesActions.setFavorites` for reordering
|
||
3. Dispatch `FavoritesActions.hydrateFavorites` only when copying values that
|
||
were already read from persistence into NgRx
|
||
4. Effects persist the two user-mutation actions; hydration is reducer-only
|
||
|
||
### XMLTV source lifecycle
|
||
|
||
Electron treats `Settings.epgUrl` as the committed global source list. Removing
|
||
an input is a draft edit; only a successful IndexedDB write authorizes source
|
||
reconciliation. A storage write failure restores the previous in-memory EPG
|
||
URLs. If subsequent cache cleanup fails, the saved URLs remain authoritative,
|
||
the settings form remains retryable and shows the existing EPG cleanup failure
|
||
message rather than claiming settings storage failed. Electron settings and
|
||
external-player paths still mirror the committed values after a cleanup failure;
|
||
a failed storage write never mirrors them. Ordinary settings saves
|
||
compare normalized source sets and skip reconciliation when they are unchanged,
|
||
so unrelated preferences do not depend on cache cleanup. An explicitly edited
|
||
EPG array requests reconciliation even when the committed URLs already match
|
||
(for example retrying a failed cleanup); removing a row marks that array dirty,
|
||
and only successful saving clears it. Startup reconciliation always runs.
|
||
|
||
`EpgSourceSettingsService` waits for `PlaylistsService.getAllPlaylists()` (which
|
||
performs the legacy playlist migration) before invoking `EPG_RECONCILE_SOURCES`.
|
||
Startup includes an empty global list and does not prune after a failed settings
|
||
read. Main verifies the completed migration flag, reads every enabled M3U
|
||
`epg_urls` list and unions them with the saved globals. Invalid ownership metadata
|
||
aborts pruning. Detected-but-disabled sources and Xtream/Stalker provider EPG are
|
||
not global XMLTV owners. No source-discovery or provider matching policy changes.
|
||
|
||
Reconciliation finds old sources in XMLTV channel, programme and per-source
|
||
metadata tables, plus queued imports. It
|
||
retires their generations before waiting for workers to exit, then uses the
|
||
`EpgWorkerRuntime` source-clear protocol. The runtime owns worker bootstrap and
|
||
shutdown; `EpgWorkerService` owns source generations and serialization, while
|
||
`runEpgFetch` owns each import’s message/timeout/exit lifecycle. Successfully cleared request candidates are forgotten
|
||
without resetting their generation fences; failed cleanups remain retryable.
|
||
Main-process and parser-worker diagnostics use `epgLogger`, applying shared
|
||
secret redaction and omitting complete URLs from error messages, nested request
|
||
data and redirect details. Error logs allowlist name/message/code/status/cause
|
||
and discard transport objects (which can contain relative request paths and raw
|
||
HTTP headers). XMLTV providers can put credentials in arbitrary path
|
||
segments or query keys; progress IPC and caller errors retain their original
|
||
values, while logs keep operation labels, counts and error codes.
|
||
Same-URL clears are serialized and replacement imports await the outstanding
|
||
clear, so an older cleanup cannot erase a newly re-added source. Source cleanup
|
||
also awaits the in-flight fetch promise when an error/timeout has already removed
|
||
the worker from the lookup but its termination is still pending.
|
||
Every removed source emits generation-scoped cancellation before cleanup starts,
|
||
including retained actionable errors whose workers have already finished. Retired
|
||
queued/running imports also cancel their own generation. Retry waits for source
|
||
reconciliation and rechecks the same error row; trust-setting continuations and
|
||
old dismissal timers cannot affect a removed or replacement row. Progress rows disappear without reporting routine worker termination as an import failure. Programmes are deleted by source; a globally keyed
|
||
channel is retained while another source still has programmes or channel metadata.
|
||
The additive `epg_channel_sources` table is created during database initialization
|
||
and records each imported source's name, logo, URL and timestamp. A per-channel
|
||
`write_order` advances inside the import transaction (including upserts and
|
||
refreshes), so restoration picks the latest surviving writer even when timestamps
|
||
collide or the clock moves backwards. Freshness still uses wall-clock timestamps.
|
||
Existing ledgers gain the column with zero for unknowable historical order. Before removing
|
||
source provenance, cleanup restores affected global channels from a surviving
|
||
snapshot, including when the legacy channel owner differs from the removed
|
||
metadata writer. Metadata-only owners survive another source's refresh or removal.
|
||
Refresh restores affected metadata before discarding that source's old snapshots;
|
||
clear-all deletes the snapshots too. Freshness reads source-specific snapshot
|
||
timestamps, so legacy sources without snapshots are refreshed on their next import
|
||
check. No legacy snapshot is guessed from global
|
||
channel metadata: its last writer may differ from its recorded owner. Affected
|
||
legacy channels without a surviving snapshot retain programmes with a neutral
|
||
XMLTV ID label, no logo/URL and no freshness timestamp until reimport. Historical
|
||
metadata with no remaining source provenance cannot be selectively reconstructed. Manual mappings are preserved and can
|
||
resolve another retained source sharing that channel ID. Legacy programmes with
|
||
unknown (`NULL`) ownership are conservatively left alone; the existing database
|
||
initialization backfill handles rows whose channel still identifies their owner.
|
||
|
||
Renderer reconciliation fences lookups before its first asynchronous step.
|
||
Imports wait for serialized reconciliation (including playlist migration), then
|
||
filter against its committed owner set. Completion increments the data revision again
|
||
and cancels earlier lookup
|
||
subscriptions, clears program caches and the selected M3U guide, and refreshes
|
||
Xtream selection and visible channel previews, plus Stalker manual mapping
|
||
overrides and bulk guides. A delayed startup import is
|
||
started only if its source still belongs to the reconciled configuration; its
|
||
completion observer is installed after settings initialization. Provider EPG
|
||
continues through its existing APIs. Playlist refresh is not EPG cache cleanup.
|
||
|
||
## Desktop source health
|
||
|
||
The source switcher and Sources rows share `SourceHealthService` in portal
|
||
shared data access. Electron checks Xtream account info, Stalker account/profile
|
||
facts through the existing session, and the first 64 KiB of M3U URL responses.
|
||
An M3U success describes the playlist URL, not every channel. Local files and
|
||
text imports have no network indicator. PWA retains its existing Xtream path.
|
||
|
||
Checks are demand-driven, never a startup readiness barrier: four at a time,
|
||
two per origin, shared between surfaces. Known results remain visible during
|
||
refresh (60-second evidence TTL, 15-second uncertain-result TTL). Background
|
||
requests have a five-second aggregate deadline; explicit checks have fifteen
|
||
seconds, including a handoff from a running background check. M3U streams are
|
||
destroyed on completion, limit, error or cancellation,
|
||
including when a provider ignores Range. Every intermediate redirect stream is
|
||
closed before the next hop is validated or requested. Header/TLS and validated redirect
|
||
policies are the same as playlist downloads. No playlist contents are replaced.
|
||
Indicator effects do not track coordinator cache reads, so publishing a result
|
||
does not restart or cancel the request. Xtream health replies stay request-local
|
||
in the Electron renderer adapter: failures reject with their serialized message,
|
||
and successful checks do not broadcast catalog response events. When newer account evidence arrives
|
||
during a probe, both the cache and the waiting caller retain that newer result.
|
||
|
||
Account-disabled/expired evidence is separate from authorization errors,
|
||
network failures and connectivity-guard pauses. Only explicit account evidence
|
||
is eligible for automatic cleanup selection. Statuses are session-only;
|
||
committed SQLite connection edits and deletions publish inventory events that
|
||
invalidate pending evidence, including deferred explicit retries. Retry now explicitly
|
||
resets the portal guard; ordinary checks do not. Credentials and response bodies
|
||
are never included in indicator text.
|
||
|
||
Explicit source-health checks retain their deadline while queued, including
|
||
background jobs promoted by Retry. Expired queued checks resolve without opening
|
||
a transport; cancellation and admission clear the queue timer.
|
||
|
||
### Desktop inactive-source cleanup
|
||
|
||
Sources > Clean up inactive sources scans every network source, independently
|
||
of page filters. The dialog uses the shared health queue with fresh checks;
|
||
only explicit account expiry/disablement is preselected. Uncertain network,
|
||
HTTP, content and authorization failures require manual selection. Local files
|
||
and text imports are excluded. Sources with active external playback, import,
|
||
refresh or deletion are skipped; identity, presence, current health and busy
|
||
state are checked again before each deletion. The shared header refresh action
|
||
tracks its M3U source ID through completion, independently of source-row state.
|
||
All shared delete actions reserve source IDs in `SourceActivityService` until
|
||
persistence and cleanup settle, including header-switcher deletion. Closed
|
||
external-player sessions and failures without a live process are not busy; the
|
||
canonical `isLiveExternalPlayerSession` predicate governs that protection.
|
||
Startup auto-refresh reserves source IDs in `SourceActivityService` until its
|
||
fetch settles. Batch persistence also skips rows deleted while a refresh was
|
||
running, and only surviving writes trigger scoped EPG fetches. Shared refresh
|
||
actions refuse IDs reserved by deletion; singular refresh persistence also
|
||
rejects missing rows inside the write queue, so late results cannot recreate them.
|
||
Evidence older than five minutes
|
||
is refreshed and requires another confirmation.
|
||
|
||
`SourceCleanupService` is dialog-scoped. User deselection survives rechecks;
|
||
recovered sources leave the candidate list. Newer uncertain evidence clears an
|
||
automatic selection and requires an explicit checkbox choice before deletion. Deletions run sequentially through
|
||
`PlaylistDeleteActionService` and the serialized `PlaylistsService` write queue.
|
||
The latter owns the single worker invocation and awaited cleanup hooks.
|
||
`PlaylistActions.playlistRemovalCommitted` updates NgRx and clears scoped EPG
|
||
request keys without a second storage deletion. Legacy request-style
|
||
`removePlaylist` still owns its persistence effect.
|
||
|
||
Stopping or destroying the dialog (including history navigation) finishes the
|
||
current source before stopping the queue; committed
|
||
work is not rolled back. Results distinguish failed deletes from successful
|
||
deletes with follow-up cleanup warnings. The UI does not resurrect a deleted
|
||
row after a cleanup failure. Downloaded files are not removed. The dialog's
|
||
confirmation covers deletion of the source and associated favorites, history
|
||
and playback positions; no deletion happens on merely opening the dialog.
|
||
|
||
## Opening playlists from the operating system
|
||
|
||
Electron only: a `.m3u`/`.m3u8` path passed
|
||
on the command line, opened through a file association, or delivered by macOS'
|
||
`open-file` event is normalized to an absolute path in the main process
|
||
(`apps/electron-backend/src/app/services/playlist-open-request.ts`) and queued there. The renderer
|
||
(`apps/web/src/app/services/playlist-open-request.service.ts`) subscribes to the
|
||
`OPEN_FILE` push **before** calling `announcePlaylistOpenListener`, which is
|
||
what makes the main process flush. `OPEN_FILE` is the only way out of the
|
||
queue, and a request stays there until the renderer confirms receipt via
|
||
`acknowledgePlaylistOpenRequest` — `webContents.send()` returns before the
|
||
listener runs, and a reload or dead render process keeps the `WebContents`
|
||
alive, so a successful push is not proof of delivery. Anything unacknowledged
|
||
is replayed to the next renderer that announces itself. The renderer
|
||
imports them on a single promise chain so a burst arrives in a deterministic
|
||
order. `addPlaylist$` in `libs/m3u-state` uses `concatMap` (not `switchMap`)
|
||
for the same reason: each action carries a different playlist, so a newer add
|
||
must never cancel an older one's write, EPG fetch and navigation. The import
|
||
itself reuses the normal file path
|
||
(`updatePlaylistFromFilePath` → `PlaylistActions.addPlaylist`), so persistence,
|
||
playlist-scoped EPG, and the navigation to the new playlist all behave exactly
|
||
like a dialog import.
|
||
|
||
The OS-level registration that makes those paths reachable is
|
||
`fileAssociations` in `electron-builder.json` — one entry per extension, each
|
||
with its own `mimeType`. Electron Builder derives all three platform
|
||
registrations from it: macOS `CFBundleDocumentTypes` (which is what makes
|
||
`open-file` fire from Finder), the NSIS registry entries, and, on Linux, the
|
||
desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for
|
||
deb/rpm/pacman. Two traps: it assigns the derived `MimeType` _after_ spreading
|
||
`linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and
|
||
must not be used; and it appends `%U` to `Exec`, so Linux file managers hand
|
||
over percent-encoded `file://` URIs rather than paths —
|
||
`createPlaylistOpenRequest` decodes them before the extension check. `%U` is
|
||
also the _plural_ exec code, so a multi-file selection arrives as one launch
|
||
with one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all
|
||
of them and `enqueueAll` queues the batch, because stopping at the first match
|
||
would silently drop the rest of the selection. Adding an exec code to
|
||
`linux.executableArgs` would suppress the `%U` but also pass that code to the
|
||
app as a real argument, so it is not an option.
|