feat(epg): manual EPG mapping for Stalker portals (#1173)

* feat(epg): manual EPG mapping for Stalker portals

Extends the manual EPG-to-channel mapping (PR #1165) to Stalker:

- shared key helper buildStalkerEpgMappingKey — playlist-scoped
  stalker:{playlistId}:{channelId} keys, mirroring the Xtream scheme
- ITV sidebar: right-click context menu with "Map EPG channel"; after
  the dialog closes with a change, the bulk EPG cache is rebuilt so the
  panel and row previews reflect the new mapping immediately
- withStalkerEpg().applyMappedItvEpg(): batch-resolves mappings for
  rendered channels (one getEpgMappingsBatch IPC per new id set) and
  overlays uploaded-XMLTV programs onto bulkItvEpgByChannel; overrides
  survive ensureBulkItvEpg reloads
- stream-resolver: mapping check in loadStalkerEpgItems (detail) and
  batched prefetch in loadStalkerEpgBatch (previews);
  mappingCandidateKeys/prefetchEpgMappings generalized beyond Xtream
- global favorites: stalker branch for the Map-EPG menu entry (item id
  extracted from the stalker::{playlistId}::{id} uid)
- docs: manual-mapping section in docs/architecture/stalker-epg.md and
  a CLAUDE.md EPG bullet covering the whole mapping feature
- tests: applyMappedItvEpg suite, stalker preview mapping in the
  stream-resolver spec, key-builder specs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(epg): harden Stalker mapping edges found in adversarial review

- stream-resolver: stalker items resolve only the playlist-scoped
  mapping key — bare tvgId/name candidates (tvgId mirrors the raw
  provider id) could only produce false matches against unrelated M3U
  mappings, and previews would disagree with the detail path
- applyMappedItvEpg: staleness guard after every await so an in-flight
  call cannot write portal A's mapped EPG into portal B's state after
  a playlist switch (the store is a root singleton)
- applyMappedItvEpg: ids are marked checked only after a successful
  lookup — a transient IPC failure no longer suppresses the mapping
  for the rest of the session
- live layout: post-dialog refresh re-applies overrides for the
  unfiltered channel list, so an active search cannot drop the playing
  channel's mapping
- global favorites: new epgMappingChanged output emitted when the
  dialog actually changed a mapping; unified live tab reloads its EPG
  previews in response

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-19 18:29:23 +02:00
1 parent b1fc23abbb
commit c707af1334
15 files changed
+624 -13

No files matched your search

+1
View File
@@ -800,6 +800,7 @@ engine` (restart required) or
- XMLTV format support
- Background parsing in worker thread
- Stored in database for quick lookup
- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/`
**TMDB Metadata Enrichment** (opt-in):
+23
View File
@@ -263,6 +263,29 @@ advantage of the richer bulk API when it is available. Row previews do not
fallback to per-channel requests in this mode; they remain empty until bulk EPG
is available.
## Manual EPG Mapping
Stalker channels carry no XMLTV identifier, so when the portal's own EPG is
missing or wrong the only uploaded-EPG entry point is a **manual mapping**:
right-click a channel in the ITV sidebar (or in global favorites) and pick
"Map EPG channel" to attach it to a channel from an uploaded XMLTV guide.
- Mappings are stored in the shared `epg_channel_mappings` table under the
playlist-scoped key `stalker:{playlistId}:{channelId}`
(`buildStalkerEpgMappingKey` in
`libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`).
- `withStalkerEpg().applyMappedItvEpg(channelIds)` batch-resolves mappings
(one `getEpgMappingsBatch` IPC per new id set) and overlays the mapped
XMLTV programs onto `bulkItvEpgByChannel`, so both the active panel and
the row previews pick them up with no template changes. Overrides are
re-merged whenever `ensureBulkItvEpg` replaces the bulk record and are
re-checked after the mapping dialog closes with a change.
- The collection views (global favorites/recent) resolve the same keys in
`StreamResolverService` (`loadStalkerEpgItems` for the detail panel,
`loadStalkerEpgBatch` + `prefetchEpgMappings` for row previews).
- Everything is gated behind `supportsEpgMapping`, so the PWA never shows
the menu entry.
## Future Enhancements
- add cache refresh / invalidation for long-running live sessions
@@ -494,6 +494,53 @@ describe('StreamResolverService', () => {
});
});
it('resolves manual EPG mappings for Stalker previews without hitting the portal', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
);
const nowMs = Date.now();
epgBridge.getEpgMappingsBatch = jest.fn().mockResolvedValue({
'stalker:stalker-1:77': 'mapped.channel.id',
});
epgBridge.getChannelPrograms = jest.fn().mockResolvedValue([
{
channel: 'mapped.channel.id',
title: 'Mapped Stalker Program',
desc: 'From uploaded XMLTV',
start: new Date(nowMs - 60_000).toISOString(),
stop: new Date(nowMs + 60_000).toISOString(),
},
]);
const epgMap = await service.loadEpgForItems([
{
uid: 'stalker::stalker-1::77',
name: 'Stalker Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '77',
tvgId: '77',
stalkerCmd: 'ffmpeg http://stalker/77',
} satisfies UnifiedCollectionItem,
]);
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1);
expect(epgMap.get('77')).toMatchObject({
title: 'Mapped Stalker Program',
});
// The mapped channel resolves from uploaded XMLTV — the portal
// short-EPG request must be skipped entirely.
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
it('loads current Stalker EPG previews for live collection rows', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -3,6 +3,7 @@ import { firstValueFrom } from 'rxjs';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey,
Channel,
EpgItem,
@@ -489,6 +490,24 @@ export class StreamResolverService {
return [];
}
// Manual mapping first — Stalker items carry no provider XMLTV id,
// so the mapping table is the only uploaded-EPG entry point.
if (this.supportsProgramLookup) {
const mapping = await this.epgBridge
.getEpgMapping(
buildStalkerEpgMappingKey(item.playlistId, channelId)
)
.catch(() => null);
if (mapping?.epgChannelId) {
const mapped = await this.epgBridge
.getChannelPrograms(mapping.epgChannelId)
.catch(() => null);
if (mapped && mapped.length > 0) {
return this.mapProgramsToEpgItems(mapped);
}
}
}
return this.fetchStalkerShortEpg(playlist, channelId, size);
}
@@ -848,6 +867,14 @@ export class StreamResolverService {
return;
}
// One batched IPC round-trip for the manual mappings of the whole
// playlist batch — mirrors loadXtreamEpgBatch.
const mappingByKey = await this.prefetchEpgMappings(
playlistId,
channels
);
const nowSeconds = Math.floor(now / 1000);
await Promise.all(
channels.map(async (channel) => {
const channelId = String(channel.stalkerId ?? '').trim();
@@ -859,6 +886,25 @@ export class StreamResolverService {
}
try {
for (const key of this.mappingCandidateKeys(
playlistId,
channel
)) {
const mappedId = mappingByKey.get(key);
if (!mappedId) continue;
const mappedItem = await this.findCurrentInXmltv(
mappedId,
nowSeconds
);
if (mappedItem) {
epgMap.set(
epgKey,
this.toPreviewProgram(mappedItem, channelId, now)
);
return;
}
}
const items = await this.fetchStalkerShortEpg(
playlist,
channelId,
@@ -1069,6 +1115,16 @@ export class StreamResolverService {
playlistId: string,
channel: UnifiedCollectionItem
): string[] {
// Stalker mappings are only ever saved under the playlist-scoped
// key, and a stalker item's tvgId mirrors its raw provider id —
// bare tvgId/name candidates could only produce false matches
// against unrelated M3U mappings.
if (channel.sourceType === 'stalker') {
return channel.stalkerId != null
? [buildStalkerEpgMappingKey(playlistId, channel.stalkerId)]
: [];
}
return [
channel.xtreamId != null
? buildXtreamEpgMappingKey(playlistId, channel.xtreamId)
@@ -1094,7 +1150,9 @@ export class StreamResolverService {
const keys = new Set<string>();
for (const channel of channels) {
if (!channel.xtreamId) continue;
if (channel.xtreamId == null && channel.stalkerId == null) {
continue;
}
for (const key of this.mappingCandidateKeys(playlistId, channel)) {
keys.add(key);
}
@@ -22,6 +22,7 @@ import {
} from '@iptvnator/ui/components';
import { SettingsStore } from '@iptvnator/services';
import {
buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey,
EpgProgram,
} from '@iptvnator/shared/interfaces';
@@ -85,6 +86,8 @@ export class GlobalFavoritesListComponent {
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly removeRequested = output<UnifiedFavoriteChannel>();
/** Emitted after the mapping dialog closes having changed a mapping. */
readonly epgMappingChanged = output<void>();
readonly contextMenuChannel = signal<EnrichedUnifiedFavorite | null>(null);
readonly contextMenuPosition = signal({
@@ -174,7 +177,9 @@ export class GlobalFavoritesListComponent {
return (
Boolean(channel.m3uChannel) ||
this.mode() === 'recent' ||
(this.supportsEpgMapping && Boolean(channel.xtreamId))
(this.supportsEpgMapping &&
(channel.xtreamId != null ||
Boolean(this.stalkerItemId(channel))))
);
}
@@ -185,20 +190,59 @@ export class GlobalFavoritesListComponent {
}
this.contextMenuTrigger().closeMenu();
const stalkerId = this.stalkerItemId(item);
const channelKey = item.m3uChannel
? resolveChannelEpgLookupKey(item.m3uChannel)
: item.xtreamId != null
? buildXtreamEpgMappingKey(item.playlistId, item.xtreamId)
: null;
: stalkerId
? buildStalkerEpgMappingKey(item.playlistId, stalkerId)
: null;
if (!channelKey) {
return;
}
void this.openEpgMappingDialog(channelKey, item);
}
private async openEpgMappingDialog(
channelKey: string,
item: UnifiedFavoriteChannel
): Promise<void> {
const before = await this.epgBridge
.getEpgMapping(channelKey)
.catch(() => null);
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: item.name,
playlistId: item.m3uChannel ? undefined : item.playlistId,
});
})
.afterClosed()
.subscribe(async () => {
const after = await this.epgBridge
.getEpgMapping(channelKey)
.catch(() => null);
if (
(after?.epgChannelId ?? null) !==
(before?.epgChannelId ?? null)
) {
this.epgMappingChanged.emit();
}
});
}
/**
* The stalker item id lives in the uid's third segment
* (`stalker::{playlistId}::{stalkerId}`) — same extraction the
* unified favorites data service uses.
*/
private stalkerItemId(channel: UnifiedFavoriteChannel): string | null {
if (channel.sourceType !== 'stalker') {
return null;
}
const id = channel.uid.split('::')[2]?.trim();
return id || null;
}
openChannelDetails(): void {
@@ -20,6 +20,7 @@
(channelSelected)="onChannelSelected($event)"
(playbackRequested)="onChannelPlaybackRequested($event)"
(channelsReordered)="onReorder($event)"
(epgMappingChanged)="onEpgMappingChanged()"
(favoriteToggled)="onFavoriteToggled($event)"
(removeRequested)="onRemoveRequested($event)"
/>
@@ -540,6 +540,10 @@ export class UnifiedLiveTabComponent {
this.activeTimeshift.set(null);
}
onEpgMappingChanged(): void {
void this.loadEpgMap(this.items());
}
private async loadEpgMap(items: UnifiedCollectionItem[]): Promise<void> {
const epgMap = await this.streamResolver.loadEpgForItems(items);
this.epgMap.set(epgMap);
@@ -1,6 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { EpgItem, Playlist } from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from '../../stalker-session.service';
import { withStalkerEpg } from './with-stalker-epg.feature';
@@ -42,6 +43,11 @@ describe('withStalkerEpg', () => {
let stalkerSessionService: {
makeAuthenticatedRequest: jest.Mock<Promise<unknown>, unknown[]>;
};
let epgBridge: {
supportsEpgMapping: boolean;
getEpgMappingsBatch: jest.Mock<Promise<unknown>, unknown[]>;
getChannelPrograms: jest.Mock<Promise<unknown>, unknown[]>;
};
beforeEach(() => {
runtimeSupportsEpg = true;
@@ -51,6 +57,11 @@ describe('withStalkerEpg', () => {
stalkerSessionService = {
makeAuthenticatedRequest: jest.fn(),
};
epgBridge = {
supportsEpgMapping: true,
getEpgMappingsBatch: jest.fn().mockResolvedValue(null),
getChannelPrograms: jest.fn().mockResolvedValue(null),
};
TestBed.configureTestingModule({
providers: [
@@ -68,6 +79,10 @@ describe('withStalkerEpg', () => {
provide: StalkerSessionService,
useValue: stalkerSessionService,
},
{
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
],
});
@@ -176,6 +191,80 @@ describe('withStalkerEpg', () => {
expect(store.selectedItvEpgPrograms()).toEqual([]);
expect(store.isLoadingBulkItvEpg()).toBe(false);
});
describe('applyMappedItvEpg', () => {
const MAPPED_PROGRAM = {
channel: 'mapped.channel.id',
title: 'Mapped Show',
desc: 'From uploaded XMLTV',
start: '2026-07-11T10:00:00.000Z',
stop: '2026-07-11T11:00:00.000Z',
};
it('overlays uploaded XMLTV programs for mapped channels', async () => {
epgBridge.getEpgMappingsBatch.mockResolvedValue({
'stalker:playlist-1:10001': 'mapped.channel.id',
});
epgBridge.getChannelPrograms.mockResolvedValue([MAPPED_PROGRAM]);
await store.applyMappedItvEpg(['10001', '10002']);
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1);
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledWith([
'stalker:playlist-1:10001',
'stalker:playlist-1:10002',
]);
expect(store.bulkItvEpgByChannel()['10001']).toEqual([
{ ...MAPPED_PROGRAM, channel: '10001' },
]);
expect(store.selectedItvEpgPrograms()).toEqual([
{ ...MAPPED_PROGRAM, channel: '10001' },
]);
});
it('checks each channel id only once per playlist session', async () => {
epgBridge.getEpgMappingsBatch.mockResolvedValue({});
await store.applyMappedItvEpg(['10001']);
await store.applyMappedItvEpg(['10001']);
expect(epgBridge.getEpgMappingsBatch).toHaveBeenCalledTimes(1);
});
it('keeps overrides when ensureBulkItvEpg replaces the bulk record', async () => {
epgBridge.getEpgMappingsBatch.mockResolvedValue({
'stalker:playlist-1:10001': 'mapped.channel.id',
});
epgBridge.getChannelPrograms.mockResolvedValue([MAPPED_PROGRAM]);
await store.applyMappedItvEpg(['10001']);
dataService.sendIpcEvent.mockResolvedValue({
js: {
'10002': [
{
name: 'Portal Show',
start_timestamp: 1_752_220_800,
stop_timestamp: 1_752_224_400,
},
],
},
});
await store.ensureBulkItvEpg(168);
const record = store.bulkItvEpgByChannel();
expect(record['10001']).toEqual([
{ ...MAPPED_PROGRAM, channel: '10001' },
]);
expect(record['10002']?.length).toBeGreaterThan(0);
});
it('does nothing when the mapping bridge is unsupported', async () => {
epgBridge.supportsEpgMapping = false;
await store.applyMappedItvEpg(['10001']);
expect(epgBridge.getEpgMappingsBatch).not.toHaveBeenCalled();
});
});
});
function buildEntry(
@@ -6,9 +6,11 @@ import {
withMethods,
withState,
} from '@ngrx/signals';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import {
buildStalkerEpgMappingKey,
EpgItem,
EpgProgram,
StalkerPortalActions,
@@ -99,7 +101,8 @@ export function withStalkerEpg() {
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService),
runtime = inject(RuntimeCapabilitiesService)
runtime = inject(RuntimeCapabilitiesService),
epgBridge = inject(EpgRuntimeBridgeService)
) => {
const storeContext = store as typeof store &
StalkerEpgFeatureStoreContract;
@@ -109,6 +112,31 @@ export function withStalkerEpg() {
};
const supportsEpg = (): boolean => runtime.supportsEpg;
// Manual EPG mapping overrides (uploaded XMLTV programs keyed
// by the normalized Stalker channel id). Kept outside the
// signal state so ensureBulkItvEpg can merge them back in
// whenever it replaces the bulk record.
const mappingOverridesById = new Map<string, EpgProgram[]>();
const mappingCheckedIds = new Set<string>();
let mappingPlaylistId: string | null = null;
const resetMappingOverrides = (): void => {
mappingOverridesById.clear();
mappingCheckedIds.clear();
mappingPlaylistId = null;
};
const mappingOverridesRecord = (): Record<
string,
EpgProgram[]
> => {
const record: Record<string, EpgProgram[]> = {};
for (const [id, programs] of mappingOverridesById) {
record[id] = programs;
}
return record;
};
const requestEpg = async (
playlist: NonNullable<
ReturnType<
@@ -229,21 +257,151 @@ export function withStalkerEpg() {
);
patchState(store, {
bulkItvEpgByChannel: bulkPrograms,
bulkItvEpgByChannel: {
...bulkPrograms,
...mappingOverridesRecord(),
},
bulkItvEpgLoaded: true,
isLoadingBulkItvEpg: false,
});
} catch (error) {
logger.warn('Bulk Stalker EPG unavailable', error);
patchState(store, {
bulkItvEpgByChannel: {},
bulkItvEpgByChannel: mappingOverridesRecord(),
bulkItvEpgLoaded: true,
isLoadingBulkItvEpg: false,
});
}
},
/**
* Overlay manual EPG mappings for the given Stalker
* channel ids onto the bulk ITV record. Each id is
* checked at most once per playlist session; channels
* with a saved mapping get their programs from the
* uploaded XMLTV guide instead of the portal EPG, which
* feeds both the active EPG panel and the row previews.
*/
async applyMappedItvEpg(
channelIds: ReadonlyArray<string | number>
): Promise<void> {
if (!epgBridge.supportsEpgMapping || !supportsEpg()) {
return;
}
const playlist = storeContext.currentPlaylist();
if (!playlist?._id) {
return;
}
const playlistId = String(playlist._id);
if (mappingPlaylistId !== playlistId) {
resetMappingOverrides();
mappingPlaylistId = playlistId;
}
const freshIds = [
...new Set(
channelIds
.map((id) => normalizeStalkerEntityId(id))
.filter(
(id) =>
id && !mappingCheckedIds.has(id)
)
),
];
if (freshIds.length === 0) {
return;
}
// The store is a root singleton, so an in-flight
// call can outlive a portal switch — bail out after
// every await instead of writing portal A's data
// into portal B's state.
const isStale = (): boolean =>
mappingPlaylistId !== playlistId ||
String(
storeContext.currentPlaylist()?._id ?? ''
) !== playlistId;
const keyById = new Map(
freshIds.map(
(id) =>
[
id,
buildStalkerEpgMappingKey(
playlistId,
id
),
] as const
)
);
let mappings: Record<string, string> | null = null;
try {
mappings = await epgBridge.getEpgMappingsBatch([
...keyById.values(),
]);
} catch (error) {
// Nothing is marked checked — the next
// invocation retries the lookup.
logger.warn(
'Stalker EPG mapping lookup failed',
error
);
return;
}
if (!mappings || isStale()) {
return;
}
let changed = false;
for (const [channelId, key] of keyById) {
const mappedEpgId = mappings[key]?.trim();
if (!mappedEpgId) {
// No mapping for this channel — a stable
// outcome, safe to dedupe.
mappingCheckedIds.add(channelId);
continue;
}
try {
const programs =
(await epgBridge.getChannelPrograms(
mappedEpgId
)) ?? [];
if (isStale()) {
return;
}
mappingCheckedIds.add(channelId);
if (programs.length === 0) {
continue;
}
mappingOverridesById.set(
channelId,
programs.map((program) => ({
...program,
channel: channelId,
}))
);
changed = true;
} catch {
// Transient failure — leave the id
// unchecked so a later call retries; the
// portal EPG stays in place meanwhile.
}
}
if (!changed || isStale()) {
return;
}
patchState(store, {
bulkItvEpgByChannel: {
...store.bulkItvEpgByChannel(),
...mappingOverridesRecord(),
},
});
},
clearBulkItvEpgCache(): void {
resetMappingOverrides();
patchState(store, initialEpgState);
},
};
@@ -72,6 +72,7 @@
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showDetailsContextMenu]="supportsEpgMapping"
[isFavorite]="
favorites.get(normalizeStalkerEntityId(item.id)) ??
false
@@ -79,6 +80,7 @@
(clicked)="playChannel(item)"
(activated)="playChannel(item, true)"
(favoriteToggled)="toggleFavorite(item)"
(contextMenuRequested)="onChannelContextMenu(item, $event)"
/>
}
}
@@ -206,3 +208,19 @@
}
</div>
</ng-template>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
</mat-menu>
@@ -63,3 +63,11 @@
padding-inline: 6px;
}
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@@ -25,6 +25,8 @@ import {
SettingsStore,
} from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { MatDialog } from '@angular/material/dialog';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
@@ -41,10 +43,12 @@ class StubChannelListItemComponent {
readonly progressPercentage = input(0);
readonly showFavoriteButton = input(false);
readonly showProgramInfoButton = input(false);
readonly showDetailsContextMenu = input(false);
readonly isFavorite = input(false);
readonly clicked = output<void>();
readonly activated = output<void>();
readonly favoriteToggled = output<void>();
readonly contextMenuRequested = output<MouseEvent>();
}
@Component({
@@ -215,6 +219,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
resolveRadioPlayback: jest.fn(),
fetchChannelEpg: jest.fn(),
ensureBulkItvEpg: jest.fn(),
applyMappedItvEpg: jest.fn().mockResolvedValue(undefined),
clearBulkItvEpgCache: jest.fn(() => {
bulkItvEpgByChannel.set({});
bulkItvEpgLoaded.set(false);
@@ -326,6 +331,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
get isElectron() {
return Boolean(window.electron);
},
get supportsEpgMapping() {
return false;
},
},
},
{ provide: PlaylistsService, useValue: playlistService },
@@ -343,6 +351,22 @@ describe('StalkerLiveStreamLayoutComponent', () => {
open: jest.fn(),
},
},
{
provide: MatDialog,
useValue: {
open: jest.fn(),
},
},
{
provide: EpgRuntimeBridgeService,
useValue: {
supportsEpgMapping: false,
getEpgMapping: jest.fn().mockResolvedValue(null),
getEpgMappingsBatch: jest
.fn()
.mockResolvedValue(null),
},
},
],
})
.overrideComponent(StalkerLiveStreamLayoutComponent, {
@@ -15,7 +15,9 @@ import {
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -23,6 +25,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ChannelListItemComponent,
ChannelListSkeletonComponent,
EpgMappingDialogComponent,
ResizableDirective,
} from '@iptvnator/ui/components';
import {
@@ -31,6 +34,7 @@ import {
SettingsStore,
} from '@iptvnator/services';
import {
buildStalkerEpgMappingKey,
Channel,
EpgItem,
EpgProgram,
@@ -50,6 +54,7 @@ import {
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
@@ -86,6 +91,7 @@ type StalkerPlayableChannel = StalkerPortalItem & {
EpgTimelineComponent,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatProgressSpinnerModule,
MatTooltipModule,
NgTemplateOutlet,
@@ -99,6 +105,8 @@ type StalkerPlayableChannel = StalkerPortalItem & {
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly playlistService = inject(PlaylistsService);
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
@@ -147,8 +155,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly selectedChannelId = this.stalkerStore.selectedItvId;
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
/** Context menu (Map EPG) */
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly contextMenuChannel = signal<StalkerItvChannel | null>(null);
readonly contextMenuPosition = signal({ x: '0px', y: '0px' });
readonly isElectron = this.runtime.isElectron;
readonly supportsEpg = this.runtime.supportsEpg;
readonly supportsEpgMapping = this.runtime.supportsEpgMapping;
readonly openStreamOnDoubleClick = computed(() =>
this.settingsStore.openStreamOnDoubleClick()
);
@@ -311,6 +326,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
this.syncBulkEpgPreviews(channels);
// Overlay manual EPG mappings for the rendered channels; the
// store dedupes per channel id, so this is cheap on rerenders.
if (this.supportsEpgMapping && channels.length > 0) {
const channelIds = channels.map((channel) => channel.id);
untracked(() =>
void this.stalkerStore.applyMappedItvEpg(channelIds)
);
}
});
effect(() => {
@@ -544,6 +568,94 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.selectedLiveEpgDate.set(selectedDate);
}
// ── Context menu (Map EPG) ─────────────────────────────────────
onChannelContextMenu(channel: StalkerItvChannel, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
}
async openEpgMapping(): Promise<void> {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const channelId = normalizeStalkerEntityId(channel.id);
if (!playlistId || !channelId) {
return;
}
const channelKey = buildStalkerEpgMappingKey(
String(playlistId),
channelId
);
const mappingBefore = await this.epgBridge
.getEpgMapping(channelKey)
.catch(() => null);
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.o_name || channel.name || channelId,
playlistId: String(playlistId),
})
.afterClosed()
.subscribe(() => {
void this.refreshEpgAfterMappingChange(
channel,
channelKey,
mappingBefore?.epgChannelId ?? null
);
});
}
/**
* Reload EPG state when the dialog actually changed the mapping —
* covers both save and removal; a plain cancel skips the reload.
*/
private async refreshEpgAfterMappingChange(
channel: StalkerItvChannel,
channelKey: string,
epgChannelIdBefore: string | null
): Promise<void> {
const mappingAfter = await this.epgBridge
.getEpgMapping(channelKey)
.catch(() => null);
if ((mappingAfter?.epgChannelId ?? null) === epgChannelIdBefore) {
return;
}
this.stalkerStore.clearBulkItvEpgCache();
const selectedId = this.selectedChannelId();
const selected = selectedId
? this.channels().find(
(item) =>
normalizeStalkerEntityId(item.id) ===
normalizeStalkerEntityId(selectedId)
)
: null;
await this.loadEpgForChannel(selected ?? channel);
// Use the unfiltered list so an active search filter cannot drop
// the playing channel's mapping override.
await this.stalkerStore.applyMappedItvEpg(
this.channels().map((item) => item.id)
);
}
private async loadEpgForChannel(item: StalkerItvChannel) {
if (!this.supportsEpg) {
this.fallbackEpgPrograms.set([]);
@@ -1,4 +1,7 @@
import { buildXtreamEpgMappingKey } from './epg-mapping-key.util';
import {
buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey,
} from './epg-mapping-key.util';
describe('buildXtreamEpgMappingKey', () => {
it('scopes the key to the playlist', () => {
@@ -19,3 +22,17 @@ describe('buildXtreamEpgMappingKey', () => {
);
});
});
describe('buildStalkerEpgMappingKey', () => {
it('scopes the key to the playlist', () => {
expect(buildStalkerEpgMappingKey('portal-a', '205')).toBe(
'stalker:portal-a:205'
);
});
it('never collides with the Xtream key space', () => {
expect(buildStalkerEpgMappingKey('p', 1)).not.toBe(
buildXtreamEpgMappingKey('p', 1)
);
});
});
@@ -6,11 +6,11 @@
* channel name) directly — those identifiers are meaningful across
* playlists, so a mapping saved once applies everywhere.
*
* Xtream stream IDs, on the other hand, are provider-local integers:
* stream 12345 in portal A has nothing to do with stream 12345 in
* portal B. Mapping keys for Xtream channels therefore embed the
* playlist ID so mappings never leak across portals. Shared between the
* renderer and the Electron main process/worker, like
* Xtream stream IDs and Stalker channel IDs, on the other hand, are
* provider-local: stream 12345 in portal A has nothing to do with
* stream 12345 in portal B. Mapping keys for portal channels therefore
* embed the playlist ID so mappings never leak across portals. Shared
* between the renderer and the Electron main process/worker, like
* `title-normalization.util.ts`.
*/
export function buildXtreamEpgMappingKey(
@@ -19,3 +19,10 @@ export function buildXtreamEpgMappingKey(
): string {
return `xtream:${playlistId}:${xtreamId}`;
}
export function buildStalkerEpgMappingKey(
playlistId: string,
stalkerId: number | string
): string {
return `stalker:${playlistId}:${stalkerId}`;
}