Files
iptvnator/docs/architecture/m3u-playlist-module.md
T

947 lines
54 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
## 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.
## Playlist Refresh And Startup Auto-Update (Electron)
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
enables opt-in worker profiling. Worker event-loop delay is read from a
request-scoped `node:perf_hooks` capture; a worker terminated before it can flush
the capture reports the metric as unavailable rather than zero. 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.
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.
- **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** slider (tick density adapts via
`timelineTickStepForScale`); 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 (incl. the zoom slider) 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. 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
### 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 |
| `MultiEpgContainerComponent` | Grid view of all channels' schedules |
## 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
- Multi-EPG modal view
- 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, 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.
### 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. 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 falls
back to `extractDrmFromRaw(channel.raw)` at playback time, so encrypted
channels of pre-upgrade playlists work without a re-import.
**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, and Shaka-error →
`PlaybackDiagnostic` classification (`PlaybackDiagnosticSource.Shaka`).
Channels with `drm.supported === false` emit a `DrmOrEncryption` diagnostic
without starting an engine.
- HTML5 player: `extension === 'mpd'` branch in `playChannel()`. 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).
## 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