Enables Xtream catch-up/timeshift from the Favorites and Recent surfaces (per-playlist and global), not just Live TV, and adds start-over replay of the currently-airing programme. Carries tv_archive/tv_archive_duration through the favorites and recently-viewed DB projections and maps them onto UnifiedCollectionItem; tv_archive_duration is interpreted as days, matching live-stream-layout.controlledArchiveDays. Closes #1138. Co-authored-by: Claude <noreply@anthropic.com>
41 KiB
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 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘
State Management (libs/m3u-state/)
State Structure
interface PlaylistState {
// Active channel being played
active: Channel | undefined;
// Whether the current route is still resolving channel data
channelsLoading: boolean;
// All channels from current playlist
channels: Channel[];
// EPG state
epg: {
epgAvailable: boolean;
activeEpgProgram: EpgProgram | undefined;
currentEpgProgram: EpgProgram | undefined;
};
// Playlist metadata (entity adapter)
playlistsMeta: {
ids: string[];
entities: Record<string, PlaylistMeta>;
selectedId: string | undefined;
allPlaylistsLoaded: boolean;
selectedFilters: PlaylistSourceFilter[];
};
}
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 |
Favorites management |
| FilterActions | setSelectedFilters |
Playlist type filtering |
Key Selectors
// 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
selectCurrentPlaylist; // 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-tab/ # Virtual scroll + search
│ ├── all-channels-tab.component.ts
│ ├── all-channels-tab.component.html
│ └── all-channels-tab.component.scss
│
├── groups-tab/ # Expansion panels + infinite scroll
│ ├── groups-tab.component.ts
│ ├── groups-tab.component.html
│ └── groups-tab.component.scss
│
├── favorites-tab/ # Drag-drop reordering
│ ├── favorites-tab.component.ts
│ ├── favorites-tab.component.html
│ └── favorites-tab.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
M3uWorkspaceRouteSessionowns route-driven channel loading for the player/sidebar routes:allandgroups.- The route session sets
channelsLoadingbeforegetPlaylist()resolves and clears it whenChannelActions.setChannelslands. ChannelListContainerComponentnow renders a dedicated skeleton state whilechannelsLoadingis true.ChannelListContainerComponentno longer clearschannelson destroy; route/session code is the single owner of shared list lifecycle during navigation.- The dedicated
/workspace/playlists/:id/favoritesand/workspace/playlists/:id/recentcollection routes do not drive the shared sidebar channel list; they default to theplaylistscope so rail links always open the current playlist view, not the last persisted global scope. - M3U favorites and recent collection rows preserve their full
Channelpayload 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
GroupsViewComponentowns the M3U-only "Manage groups" action and dialog inlibs/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. ChannelListContainerComponentreadshiddenGroupTitlesfrom the active M3U playlist metadata and passes it into the groups view. Saving dialog changes dispatchesPlaylistActions.updatePlaylistMeta.PlaylistsService.updatePlaylistMeta()persistshiddenGroupTitlesinto 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
AllChannelsViewComponentowns sorting for the all-channels list and persists the selected mode underm3u-all-channels-sort-mode.GroupsViewComponentowns sorting for the selected group's channel pane and persists the selected mode underm3u-groups-channel-sort-mode.- Both views support three modes:
Playlist Order,Name A-Z, andName Z-A.Playlist Orderis 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.
EnrichedChannel Pattern
For performance optimization, channels are pre-enriched with EPG data:
interface EnrichedChannel extends Channel {
epgProgram: EpgProgram | null | undefined;
logo: string; // Playlist tvg-logo first, XMLTV icon fallback second
progressPercentage: number; // Pre-computed by parent
}
The renderer now keeps two lookup maps for M3U collection views:
channelEpgMapfor current-program preview datachannelIconMapfor XMLTV channel icon fallback data
Logo resolution is runtime-only and follows this rule:
- playlist
tvg-logo - matched XMLTV
<channel><icon src="..."> - generic
live_tvfallback in the list item component
EPG lookup keys use the same precedence in both program and icon paths:
tvg-idtvg-name- 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 returnsNaN).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-carkey → {epgProgram, progressPercentage}map (used by all-channels/groups/ favorites). Callers read theirprogressTick()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) offwhen/startMsprimitives.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 chosenlive/timeshiftaction.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; Stalkerget_epg_info; Xtreamget_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. SeeTimelineScrollController.maybeAutoFocus/focusCurrentPrograminepg-timeline-scroll.controller.ts. - Controlled component.
app-epg-timelineis presentation-only: it takesprograms,archivePlaybackAvailable,archiveDays,activeProgram,isLivePlayback,loading,emptyReason,selectedDate,collapsed,summaryand emitsprogramActivated,returnToLive,selectedDateChange,openEpgSettings,retry,collapsedChange. The host layout owns playback, persists the collapse state (liveEpgPanelStatein localStorage), and (for the M3U player) theEpgActions.setCurrentEpgProgram/setEpgAvailableFlag/setActiveEpgProgramdispatches. 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 formerapp-live-epg-panelwrapper 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
summaryexists (.epg-timeline__subtitle) — readable title style, not the uppercase mono label. During timeshift it switches to the archive programme with ahistoryicon and cyan accent (.is-arch). It falls back to the staticsourceLabel(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 theribbonstate only (nothing to jump to or zoom otherwise), andshowDateStepper()keeps the date stepper forribbonandempty-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(Xtreamtv_archive, M3Ucatchup-*); Stalker is schedule-only (dimmed past + a notice, no false buttons). The "i" button opens the sharedapp-epg-item-descriptiondialog with a state-aware action. - Empty / error states.
emptyReasonselects one of six states (loadingskeleton,empty-day,channel-unmapped,provider-no-epg,m3u-needs-setup,error) viaapp-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 hostflex: 1-fills the area below the toolbar and centres within that (compact icon/title/sub), rather than a fixedmin-heightthat used to overflow the compact inline panel and push the icon/text — and theempty-dayaction buttons — below the visible edge.empty-dayitself is decided byhasProgramsForDateKey, 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 toempty-dayafter 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-wideblock (it flips above the block when the panel is near the screen bottom); (D) a px-per-minute zoom slider (tick density adapts viatimelineTickStepForScale); 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 childapp-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 keepsflex: 1and 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
widetier (rendered width ≥132px, i.e. long programmes and/or zoomed in) and the programme has adesc, a dimmed (--text-secondary) preview of the description renders under the title (.epg-timeline__block-desc). Gated towideonly, 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-tiertime/title/descuseflex-shrink: 0so 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'soverflow: 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.manualEpgUrlsstores URLs the user explicitly added for this playlist, including detected catalog URLs the user manually enabled. -
Playlist.disabledEpgUrlsstores 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 whoseguides/<country>path matches playlist hints such astvg-countryor the country suffix intvg-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-stateauto-fetches enabledepgUrlswhen 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 omitepgUrlspreserve the previous fetch key, while an explicit emptyepgUrlslist 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_urlon imported programs so current program lookups can ask for the active playlist's EPG sources first. Existing databases backfill this column fromepg_channels.source_urlonce, in bounded batches, after the scoped indexes are created. When multiple EPG files reuse the same XMLTV channel id, the channel row keeps its originalsource_urlattribution instead of being overwritten by the last imported source; program scoping remains source-specific throughepg_programs.source_url. ChannelListContainerComponentenables EPG rows when either global settings URLs or the active M3U playlist hasepgUrls. 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 whoseepgUrlsarrive 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'sLOWER()/COLLATE NOCASEonly fold ASCII, so for non-ASCII names (Cyrillic, Greek, …) aLOWER()-only match would miss channels whose M3U name and EPGdisplay_nameshare the same casing — the raw exact match keeps parity with the timeline's single-channel exact-display-name lookup, and the JSresolveChannelMetadataCandidatethen folds with full-UnicodetoLowerCase(). The "airing now" window is compared with timezone-aware SQLitedatetime()on both sides (EpgQueryService.isAiringAt), not raw string comparison: stored EPG timestamps often carry an offset (e.g.+03:00) whilenowis 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 unscopedgetChannelProgramslookup. This keeps the channel-list "now" line consistent with the timeline when a channel's row and its programmes carry differentsource_urlvalues (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 nosource_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 requestedsource_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_urland 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,favoriteToggled - Features: Workspace search, persisted channel sorting, virtual scrolling, no-results placeholder
GroupsViewComponent
- Inputs: Same as AllChannelsTab +
groupedChannels - Outputs:
channelSelected,favoriteToggled - 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,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,favoriteToggled,recentItemRemoved - Features: Read-only channel details context menu, row-level and context-menu removal
EPG Integration
EpgService (@iptvnator/epg/data-access)
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-statepreference - 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-utilsfrom channel metadata and the archived program start time. - Supported replay precedence:
catchup.sourceif it is an HTTP(S) URL. IPTVNator rewrites or appends standardutcandlutcquery params on that URL.- Legacy same-stream shift playback when
catchup.type === 'shift'. In that case IPTVNator rewrites or appendsutcandlutconchannel.url. - Legacy same-stream shift fallback when no explicit catch-up mode is
declared, archive-day metadata exists (
tvg.rec,timeshift, orcatchup.days), andchannel.urlitself is an HTTP(S) stream URL. This covers providers that only advertise archive retention such astvg-rec="7"but still expect standardutcandlutcquery params on the live URL.
tvg.rec,timeshift, andcatchup.daysstill 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 useactivePlaybackUrl ?? 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 ownactiveTimeshiftsignal, resolves the replay URL withresolveM3uCatchupUrl, 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_FAILEDsnackbar instead of doing nothing.
Interfaces
Channel Interface
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;
};
}
Playlist State Additions
interface PlaylistState {
active: Channel | undefined;
activePlaybackUrl: string | null;
currentEpgProgram: EpgProgram | undefined;
epgAvailable: boolean;
channels: Channel[];
}
EpgProgram Interface
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
/playlists/:id # Video player with playlist
/iptv # Default IPTV route
Adding New Features
To add a new tab to channel list:
- Create component in
channel-list-container/new-tab/ - Accept inputs:
channels,channelEpgMap,progressTick,shouldShowEpg,activeChannelUrl - Emit
channelSelectedoutput - Add to parent template and imports
To add EPG-related features:
- Use
EpgServicefor data fetching - Subscribe to
channelEpgMapsignal for current programs - Dispatch
EpgActionsfor state updates
To modify favorites behavior:
- Dispatch
FavoritesActions.updateFavoritesfor toggle - Dispatch
FavoritesActions.setFavoritesfor reordering - Effects automatically persist to database