feat: manual EPG-to-channel mapping with mapping fallback (#1165)

* feat(epg): manual EPG-to-channel mapping with mapping fallback in all EPG paths

* fix: epg mapping in live tv list

* fix(epg): harden manual EPG mapping — upgrade safety, perf, playlist-scoped keys

Follow-up fixes on top of the manual EPG-to-channel mapping feature:

- epg-database: dedupe existing epg_programs rows before creating the
  unique (channel_id, start, title) index — a plain CREATE UNIQUE INDEX
  crashed the EPG worker on upgrade when historical duplicates exist;
  replace INSERT OR REPLACE with ON CONFLICT DO UPDATE so the
  epg_programs_fts delete trigger is not bypassed (REPLACE skips delete
  triggers unless recursive_triggers is on), with a plain-INSERT
  fallback when the index cannot be created
- db: add idx_content_epg_channel — the mapping fallback scanned the
  whole content table on every single-channel EPG lookup
- keys: scope Xtream mapping keys per playlist via shared
  buildXtreamEpgMappingKey (xtream:{playlistId}:{id}) — bare stream ids
  collide across portals; the backend fallback now joins categories to
  resolve the playlist id
- pwa: hide "Map EPG channel" entries behind the supportsEpgMapping
  capability — the menu item was a dead end in the PWA
- parser: parse the XMLTV offset sign from the string — Math.sign(0)
  dropped the minutes of ±00:xx offsets
- cleanup: typed window.electron access instead of ad-hoc casts, drop
  unused resolveChannelId and dialog data field, shared
  EpgMappingDialogComponent.open() for all seven call sites
- dialog UX: minimum-characters search hint, save/remove snackbars,
  current mapping shows the EPG channel display name,
  takeUntilDestroyed on the search stream
- i18n: fill the new keys in all 17 locales
- tests: cover mapping CRUD/search escaping, the dedup-index guard,
  offset parsing and playlist-scoped keys; update stale stream-resolver
  specs for the new 50-item limit and 10s timeout

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

* fix(epg): escape backslashes in EPG channel search LIKE pattern

CodeQL js/incomplete-sanitization: a lone trailing backslash in the
search term paired with the closing wildcard under the ESCAPE clause
and corrupted the pattern.

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

* perf(epg): batch mapping lookups in the viewport preview queue

Expose the existing getEpgMappingsBatch operation over a new
EPG_MAPPING_GET_BATCH IPC channel and use it in resolveManualMappings —
the per-entry lookup issued one IPC round-trip per visible channel on
every scroll event.

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

* perf(epg): batch mapping prefetch in the collection preview loader

Resolve all candidate mapping keys for an Xtream preview batch with a
single getEpgMappingsBatch IPC call instead of per-channel lookups.
Also fix a worker early-exit: a channel without tvgId/name returned out
of the shared-iterator loop and silently killed one of the three
concurrent preview workers.

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

---------

Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-19 09:00:14 +02:00
1 parent 45b6d8a041
commit ec6fc403a3
60 files changed
+2343 -91

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
export * from './lib/channel-list-container/channel-list-container.component';
export * from './lib/channel-list-container/channel-details-dialog/channel-details-dialog.component';
export * from './lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component';
export * from './lib/channel-list-container/channel-list-item/channel-list-item.component';
export * from './lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component';
export * from './lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component';
@@ -110,6 +110,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -16,6 +16,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import {
@@ -28,6 +29,7 @@ import {
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
const ALL_CHANNELS_SORT_STORAGE_KEY = 'm3u-all-channels-sort-mode';
@@ -51,6 +53,8 @@ export type { ChannelEpgMetadata } from '../epg-enrichment.util';
})
export class AllChannelsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
@@ -202,4 +206,22 @@ export class AllChannelsViewComponent {
width: 'calc(100vw - 32px)',
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
}
@@ -227,6 +227,12 @@
</mat-dialog-content>
<mat-dialog-actions class="channel-dialog__actions">
@if (supportsEpgMapping) {
<button mat-stroked-button (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
{{ 'CHANNELS.MAP_EPG' | translate }}
</button>
}
<button mat-stroked-button mat-dialog-close class="channel-dialog__close">
{{ 'CLOSE' | translate }}
</button>
@@ -1,11 +1,18 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { getM3uArchiveDays, isM3uCatchupPlaybackSupported } from '@iptvnator/shared/m3u-utils';
import { Channel } from '@iptvnator/shared/interfaces';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
interface ChannelDetailField {
readonly empty?: boolean;
@@ -35,6 +42,9 @@ interface HeroStat {
})
export class ChannelDetailsDialogComponent {
readonly channel = inject<Channel>(MAT_DIALOG_DATA);
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly archiveDays = getM3uArchiveDays(this.channel);
readonly catchupAvailable = this.archiveDays > 0;
@@ -182,4 +192,16 @@ export class ChannelDetailsDialogComponent {
return { labelKey, monospace, value: normalized };
}
openEpgMapping(): void {
const channelKey = resolveChannelEpgLookupKey(this.channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: this.channel.name ?? channelKey,
});
}
}
@@ -0,0 +1,77 @@
<h2 mat-dialog-title>{{ 'EPG_MAPPING_DIALOG.TITLE' | translate }}</h2>
<mat-dialog-content>
<p class="epg-mapping-subtitle">
{{ 'EPG_MAPPING_DIALOG.SUBTITLE' | translate : { channelName: data.channelName } }}
</p>
<div class="epg-mapping-current">
<span class="epg-mapping-current__label">
{{ 'EPG_MAPPING_DIALOG.CURRENT_MAPPING' | translate }}:
</span>
<span class="epg-mapping-current__value">
{{ currentMapping() || ('EPG_MAPPING_DIALOG.NO_MAPPING' | translate) }}
</span>
</div>
<mat-form-field class="epg-mapping-search" appearance="outline">
<mat-label>{{ 'EPG_MAPPING_DIALOG.SEARCH_PLACEHOLDER' | translate }}</mat-label>
<input
matInput
[ngModel]="searchTerm()"
(ngModelChange)="onSearchInput($event)"
[placeholder]="'EPG_MAPPING_DIALOG.SEARCH_PLACEHOLDER' | translate"
/>
<mat-icon matPrefix>search</mat-icon>
@if (loading()) {
<mat-spinner matSuffix diameter="20" />
}
@if (!hasSearchTerm()) {
<mat-hint>
{{
'EPG_MAPPING_DIALOG.SEARCH_HINT'
| translate: { min: searchMinChars }
}}
</mat-hint>
}
</mat-form-field>
@if (hasSearchTerm()) {
<div class="epg-mapping-results">
@for (channel of results(); track channel.id) {
<button
class="epg-mapping-result"
[class.epg-mapping-result--selected]="selectedId() === channel.id"
(click)="selectChannel(channel.id)"
>
<span class="epg-mapping-result__name">{{ channel.displayName }}</span>
<span class="epg-mapping-result__id">{{ channel.id }}</span>
</button>
} @empty {
<div class="epg-mapping-empty">
<mat-icon>search_off</mat-icon>
<span>{{ 'EPG_MAPPING_DIALOG.NO_RESULTS' | translate }}</span>
</div>
}
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (currentMapping()) {
<button mat-button color="warn" (click)="removeMapping()">
{{ 'EPG_MAPPING_DIALOG.REMOVE' | translate }}
</button>
}
<button mat-button (click)="close()">
{{ 'EPG_MAPPING_DIALOG.CLOSE' | translate }}
</button>
<button
mat-raised-button
color="primary"
[disabled]="!selectedId()"
(click)="save()"
>
{{ 'EPG_MAPPING_DIALOG.SAVE' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,95 @@
.epg-mapping-subtitle {
margin: 0 0 16px;
color: var(--mat-sys-on-surface-variant);
font-size: 14px;
}
.epg-mapping-current {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 8px 12px;
border-radius: 8px;
background: var(--mat-sys-surface-container-high);
&__label {
font-size: 12px;
color: var(--mat-sys-on-surface-variant);
}
&__value {
font-size: 14px;
font-weight: 500;
font-family: monospace;
}
}
.epg-mapping-search {
width: 100%;
margin-bottom: 8px;
}
.epg-mapping-results {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 300px;
overflow-y: auto;
}
.epg-mapping-result {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
cursor: pointer;
text-align: left;
width: 100%;
color: inherit;
transition: background 0.15s, border-color 0.15s;
&:hover {
background: var(--mat-sys-surface-container-high);
}
&--selected {
border-color: var(--mat-sys-primary);
background: color-mix(
in srgb,
var(--mat-sys-primary) 10%,
transparent
);
}
&__name {
font-size: 14px;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
&__id {
font-size: 11px;
color: var(--mat-sys-on-surface-variant);
font-family: monospace;
}
}
.epg-mapping-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 40px;
width: 40px;
height: 40px;
}
}
@@ -0,0 +1,205 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { debounceTime, from, Subject, switchMap } from 'rxjs';
export interface EpgMappingDialogData {
channelKey: string;
channelName: string;
/** Owning playlist for portal channels; omitted for M3U lookup keys. */
playlistId?: string | null;
}
export interface EpgMappingDialogResult {
channelKey: string;
epgChannelId: string;
}
const SEARCH_MIN_CHARS = 2;
@Component({
selector: 'app-epg-mapping-dialog',
templateUrl: './epg-mapping-dialog.component.html',
styleUrl: './epg-mapping-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
TranslatePipe,
],
})
export class EpgMappingDialogComponent {
readonly data = inject<EpgMappingDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(
MatDialogRef<EpgMappingDialogComponent, EpgMappingDialogResult>
);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly epgService = inject(EpgService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
readonly searchMinChars = SEARCH_MIN_CHARS;
readonly searchTerm = signal('');
readonly loading = signal(false);
readonly results = signal<
Array<{ id: string; displayName: string; iconUrl: string | null }>
>([]);
readonly selectedId = signal<string | null>(null);
readonly currentMapping = signal<string | null>(null);
private readonly search$ = new Subject<string>();
/** Open the dialog with the shared sizing used by every caller. */
static open(
dialog: MatDialog,
data: EpgMappingDialogData
): MatDialogRef<EpgMappingDialogComponent, EpgMappingDialogResult> {
return dialog.open(EpgMappingDialogComponent, {
data,
width: '500px',
maxHeight: '90vh',
});
}
constructor() {
// Fetch the current mapping (if any) on open — callers don't know it.
this.loadCurrentMapping();
this.search$
.pipe(
debounceTime(300),
switchMap((term) => {
if (term.trim().length < SEARCH_MIN_CHARS) {
return from(Promise.resolve([]));
}
this.loading.set(true);
const promise = this.epgBridge.searchEpgChannels(term);
return from(promise ?? Promise.resolve([]));
}),
takeUntilDestroyed()
)
.subscribe({
next: (items) => {
this.results.set(items ?? []);
this.loading.set(false);
},
error: () => {
this.results.set([]);
this.loading.set(false);
},
});
}
readonly hasSearchTerm = computed(
() => this.searchTerm().trim().length >= SEARCH_MIN_CHARS
);
onSearchInput(value: string): void {
this.searchTerm.set(value);
this.search$.next(value);
}
selectChannel(id: string): void {
this.selectedId.set(id);
}
save(): void {
const epgChannelId = this.selectedId();
if (!epgChannelId) return;
this.epgBridge
.setEpgMapping(
this.data.channelKey,
epgChannelId,
this.data.playlistId ?? undefined
)
.then((result) => {
if (!result?.success) {
this.notify('EPG_MAPPING_DIALOG.SAVE_FAILED');
return;
}
this.epgService.clearCache();
this.notify('EPG_MAPPING_DIALOG.SAVED');
this.dialogRef.close({
channelKey: this.data.channelKey,
epgChannelId,
});
});
}
removeMapping(): void {
this.epgBridge.deleteEpgMapping(this.data.channelKey).then((result) => {
if (!result?.success) {
this.notify('EPG_MAPPING_DIALOG.SAVE_FAILED');
return;
}
this.epgService.clearCache();
this.notify('EPG_MAPPING_DIALOG.REMOVED');
this.dialogRef.close();
});
}
close(): void {
this.dialogRef.close();
}
/**
* Resolve the stored mapping and enrich it with the EPG channel's
* display name so the user sees "BBC One (bbc.one.uk)" instead of a
* bare ID.
*/
private loadCurrentMapping(): void {
this.epgBridge.getEpgMapping(this.data.channelKey).then(async (m) => {
const mappedId = m?.epgChannelId ?? null;
if (!mappedId) {
this.currentMapping.set(null);
return;
}
this.currentMapping.set(mappedId);
try {
const matches =
(await this.epgBridge.searchEpgChannels(mappedId, 10)) ??
[];
const exact = matches.find((r) => r.id === mappedId);
if (exact?.displayName && exact.displayName !== mappedId) {
this.currentMapping.set(
`${exact.displayName} (${mappedId})`
);
}
} catch {
// Display name enrichment is cosmetic — keep the bare ID.
}
});
}
private notify(key: string): void {
this.snackBar.open(this.translate.instant(key), undefined, {
duration: 2000,
});
}
}
@@ -69,6 +69,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -17,9 +17,11 @@ import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -39,6 +41,8 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
})
export class FavoritesViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
@@ -152,6 +156,25 @@ export class FavoritesViewComponent {
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
@@ -331,6 +331,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -19,6 +19,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
@@ -30,6 +31,7 @@ import {
sortPlaylistChannelItems,
} from '../channel-list-sort.util';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
import { ResizableDirective } from '../../resizable/resizable.directive';
@@ -72,6 +74,8 @@ interface FilteredGroupView {
})
export class GroupsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly contextMenuTrigger =
@@ -490,6 +494,25 @@ export class GroupsViewComponent {
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
@@ -62,6 +62,12 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
@@ -12,7 +12,10 @@ import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import {
@@ -40,6 +43,8 @@ export interface RecentViewItem {
})
export class RecentViewComponent {
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
@@ -133,6 +138,25 @@ export class RecentViewComponent {
});
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
}
EpgMappingDialogComponent.open(this.dialog, {
channelKey,
channelName: channel.name ?? channelKey,
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {