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,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}`;
}