mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
b1fc23abbb
commit
c707af1334
15 files changed
+624
-13
No files matched your search
@@ -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):
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+47
-3
@@ -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 {
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
(channelSelected)="onChannelSelected($event)"
|
||||
(playbackRequested)="onChannelPlaybackRequested($event)"
|
||||
(channelsReordered)="onReorder($event)"
|
||||
(epgMappingChanged)="onEpgMappingChanged()"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(removeRequested)="onRemoveRequested($event)"
|
||||
/>
|
||||
|
||||
+4
@@ -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);
|
||||
|
||||
+89
@@ -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(
|
||||
|
||||
+161
-3
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
+18
@@ -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>
|
||||
+8
@@ -63,3 +63,11 @@
|
||||
padding-inline: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-context-menu-anchor {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
+24
@@ -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, {
|
||||
|
||||
+112
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user