mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
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:
60 files changed
+2343
-91
No files matched your search
@@ -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';
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+22
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
+23
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+77
@@ -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>
|
||||
+95
@@ -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;
|
||||
}
|
||||
}
|
||||
+205
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
+23
@@ -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) {
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+23
@@ -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) {
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+24
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user