diff --git a/CLAUDE.md b/CLAUDE.md index ff9f9e8fc..47bbfd6d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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): diff --git a/docs/architecture/stalker-epg.md b/docs/architecture/stalker-epg.md index 80bf997fe..20367b344 100644 --- a/docs/architecture/stalker-epg.md +++ b/docs/architecture/stalker-epg.md @@ -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 diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index b323ed2cc..e477acb5a 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -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) + ); + + 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({ diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 0b487fafd..57cf4d129 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -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(); 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); } diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts index 52a7afaff..e3e0c25d9 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -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(); readonly favoriteToggled = output(); readonly removeRequested = output(); + /** Emitted after the mapping dialog closes having changed a mapping. */ + readonly epgMappingChanged = output(); readonly contextMenuChannel = signal(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 { + 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 { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 47192240f..08714535e 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -20,6 +20,7 @@ (channelSelected)="onChannelSelected($event)" (playbackRequested)="onChannelPlaybackRequested($event)" (channelsReordered)="onReorder($event)" + (epgMappingChanged)="onEpgMappingChanged()" (favoriteToggled)="onFavoriteToggled($event)" (removeRequested)="onRemoveRequested($event)" /> diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 1d7ea6e35..ba0d5702f 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -540,6 +540,10 @@ export class UnifiedLiveTabComponent { this.activeTimeshift.set(null); } + onEpgMappingChanged(): void { + void this.loadEpgMap(this.items()); + } + private async loadEpgMap(items: UnifiedCollectionItem[]): Promise { const epgMap = await this.streamResolver.loadEpgForItems(items); this.epgMap.set(epgMap); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts index 499f901d8..355f1aefe 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts @@ -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, unknown[]>; }; + let epgBridge: { + supportsEpgMapping: boolean; + getEpgMappingsBatch: jest.Mock, unknown[]>; + getChannelPrograms: jest.Mock, 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( diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts index ce0748177..02b0110bb 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts @@ -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(); + const mappingCheckedIds = new Set(); + let mappingPlaylistId: string | null = null; + + const resetMappingOverrides = (): void => { + mappingOverridesById.clear(); + mappingCheckedIds.clear(); + mappingPlaylistId = null; + }; + + const mappingOverridesRecord = (): Record< + string, + EpgProgram[] + > => { + const record: Record = {}; + 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 + ): Promise { + 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 | 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); }, }; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 4cc41cec3..a187c2db6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -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 @@ } + + + + + + diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss index 40c6c3558..57ccc3f1c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.scss @@ -63,3 +63,11 @@ padding-inline: 6px; } } + +.channel-context-menu-anchor { + position: fixed; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 49c1d6c9b..dac8cf39c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -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(); readonly activated = output(); readonly favoriteToggled = output(); + readonly contextMenuRequested = output(); } @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, { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index e50766d38..02e33442d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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('contextMenuTrigger'); + readonly contextMenuChannel = signal(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 { + 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 { + 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([]); diff --git a/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts b/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts index fd7453c38..62c0d54e6 100644 --- a/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts +++ b/libs/shared/interfaces/src/lib/epg-mapping-key.util.spec.ts @@ -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) + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts b/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts index 5173ea8d3..8f0b024e2 100644 --- a/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts +++ b/libs/shared/interfaces/src/lib/epg-mapping-key.util.ts @@ -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}`; +}