feat(epg): normalize channel data and fix template bindings

Convert channel structures to a consistent and adapt templates
to read new safely.

- Add accessors in EListComponent:
  - getChannelName(channel) returns first displayName or ''
  - getChannelIcon(channel) returns first icon.src or ''
  These helpers make reading channel metadata robust against missing or
  differently-shaped data.

- Build channel object with normalized fields in ngOnInit:
  - set id, displayName[], url[], icon[] rather than raw name/icon
  - ensure fallbacks to empty strings/arrays to avoid undefined values

- Update InfoOverlay template to safely access program fields:
  - use epgProgram?.icon?.[0]?.src for image source
  - use epgProgram?.title?.[0]?.value and epgProgram?.desc?.[0]?.value

- Fix MultiEpg templates for property access and tracking:
  - replace invalid template boolean expressions with safe non-null
    assertion where needed ((hourWidth$ | async)! ...)
  - use track item.id instead of track $any(item).channel
  - call getChannelIcon/getChannelName and render icon/name via helpers

These changes prevent runtime errors when fields are missing or have
array-wrapped values, improve template safety, and standardize channel
data handling across the EPG UI.
This commit is contained in:
4gray committed 2025-10-23 21:54:11 +02:00
1 parent 8e5a643fac
commit 8b0db47caf
7 files changed
+133 -81

No files matched your search

@@ -130,7 +130,8 @@ export class ChannelListContainerComponent implements OnDestroy {
this.selected = channel;
this.store.dispatch(PlaylistActions.setActiveChannel({ channel }));
const epgChannelId = channel?.name.trim();
// Use tvg-id for EPG matching, fallback to channel name if not available
const epgChannelId = channel?.tvg?.id?.trim() || channel?.name.trim();
if (epgChannelId) {
this.epgService.getChannelPrograms(epgChannelId);
@@ -1,42 +1,42 @@
<h2 mat-dialog-title>
{{ 'EPG.PROGRAM_DIALOG.PROGRAM_DETAILS' | translate }}
{{ 'EPG.PROGRAM_DIALOG.PROGRAM_DETAILS' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
@if (epgProgram?.title?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.TITLE' | translate }}
</div>
<p data-test="title">{{ epgProgram.title }}</p>
@if (epgProgram.title[0].lang) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.LANGUAGE' | translate }}
</div>
@if (epgProgram?.title?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.TITLE' | translate }}
</div>
<p data-test="title">{{ epgProgram.title[0]?.value }}</p>
@if (epgProgram.title[0].lang) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.LANGUAGE' | translate }}
</div>
}
<p data-test="lang">{{ epgProgram.title[0].lang }}</p>
}
@if (epgProgram?.category?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.CATEGORY' | translate }}
</div>
<p data-test="category">{{ epgProgram.category[0]?.value }}</p>
}
@if (epgProgram?.desc?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.DESCRIPTION' | translate }}
</div>
<p data-test="desc">{{ epgProgram.desc[0]?.value }}</p>
}
@if (epgProgram?.rating?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}
</div>
<p data-test="rating">
{{ epgProgram.rating[0].value }} [{{ epgProgram.rating[0].system }}]
</p>
}
<p data-test="lang">{{ epgProgram.title[0].lang }}</p>
}
@if (epgProgram?.category?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.CATEGORY' | translate }}
</div>
<p data-test="category">{{ epgProgram.category }}</p>
}
@if (epgProgram?.desc?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.DESCRIPTION' | translate }}
</div>
<p data-test="desc">{{ epgProgram.desc }}</p>
}
@if (epgProgram?.rating?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}
</div>
<p data-test="rating">
{{ epgProgram.rating[0].value }} [{{ epgProgram.rating[0].system }}]
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -5,18 +5,19 @@
<div id="channel-header" color="primary">
<div class="channel-info">
<div class="channel-icon">
@if (channel?.icon) {
<img [src]="channel.icon" width="48" />
@if (getChannelIcon(channel)) {
<img [src]="getChannelIcon(channel)" width="48" alt="icon" />
}
</div>
<div class="channel-details">
<div class="channel-name">
{{ channel?.name }}
{{ getChannelDisplayName(channel) }}
</div>
<div class="program-name">
{{
playingNow
? playingNow.title
? (playingNow.title?.[0]?.value || 'EPG.LIVE_STREAM'
| translate)
: ('EPG.LIVE_STREAM' | translate)
}}
</div>
@@ -67,7 +68,7 @@
[item]="program"
/>
</div>
<p matListItemLine>{{ program?.title }}</p>
<p matListItemLine>{{ program?.title?.[0]?.value }}</p>
</mat-list-option>
} @else {
<mat-list-option
@@ -91,7 +92,10 @@
[item]="program"
/>
</div>
<p matListItemLine [innerHTML]="program.title"></p>
<p
matListItemLine
[innerHTML]="program.title?.[0]?.value"
></p>
</mat-list-option>
}
<mat-divider />
@@ -85,6 +85,32 @@ export class EpgListComponent implements OnInit, OnDestroy {
moment().format(DATE_FORMAT)
);
/**
* Helper function to get channel display name
*/
getChannelDisplayName(channel: EpgChannel): string {
if (
!channel ||
!channel.displayName ||
channel.displayName.length === 0
) {
return '';
}
// Return first available display name
return channel.displayName[0]?.value || '';
}
/**
* Helper function to get channel icon
*/
getChannelIcon(channel: EpgChannel): string {
if (!channel || !channel.icon || channel.icon.length === 0) {
return '';
}
// Return first available icon src
return channel.icon[0]?.src || '';
}
/** Filtered EPG programs based on selected date */
filteredItems$ = combineLatest([this.items$, this.selectedDate$]).pipe(
map(([items, selectedDate]) =>
@@ -103,11 +129,19 @@ export class EpgListComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.timeshiftUntil$ = this.store.select(selectActive).pipe(
map((active) => {
// Create EpgChannel with proper structure
const displayNames = active?.name
? [{ lang: '', value: active.name }]
: [];
const icons = active?.tvg?.logo
? [{ src: active.tvg.logo }]
: [];
this.channel = {
id: active?.tvg?.id,
name: active?.name,
url: [active?.url],
icon: [active?.tvg?.logo],
id: active?.tvg?.id || '',
displayName: displayNames,
url: active?.url ? [active.url] : [],
icon: icons,
};
return (
active?.tvg?.rec ||
@@ -6,17 +6,18 @@
>
<div class="channel-logo">
<img
[src]="epgProgram?.icon || channel?.tvg?.logo"
[src]="epgProgram?.icon?.[0]?.src || channel?.tvg?.logo"
width="68"
onerror="this.style.display='none'"
alt="channel icon"
/>
</div>
<div class="content">
<div class="program-name">
{{ epgProgram?.title }} | {{ channel.name }}
{{ epgProgram?.title?.[0]?.value }} | {{ channel.name }}
</div>
<div class="program-description">
{{ epgProgram?.desc }}
{{ epgProgram?.desc?.[0]?.value }}
</div>
<div class="program-progress">
<div
@@ -24,10 +24,10 @@
<div class="today-date">
{{ today | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }}
</div>
<button mat-icon-button (click)="zoomIn()" [disabled]="(hourWidth$ | async) >= 800">
<button mat-icon-button (click)="zoomIn()" [disabled]="(hourWidth$ | async)! >= 800">
<mat-icon>zoom_in</mat-icon>
</button>
<button mat-icon-button [disabled]="(hourWidth$ | async) <= 50" (click)="zoomOut()">
<button mat-icon-button [disabled]="(hourWidth$ | async)! <= 50" (click)="zoomOut()">
<mat-icon>zoom_out</mat-icon>
</button>
<div class="navigation-buttons">
@@ -51,7 +51,7 @@
</div>
<div class="parent" #epgContainer>
<svg id="channels-column">
@for (item of channels$ | async; track $any(item).channel; let i = $index) {
@for (item of channels$ | async; track item.id; let i = $index) {
<g>
<!-- channel name -->
<g class="channel">
@@ -67,14 +67,14 @@
[attr.y]="barHeight * i + barHeight"
>
<div class="channel-name">
@if (item.icon) {
@if (getChannelIcon(item)) {
<img
[src]="item.icon"
[alt]="item.name"
[src]="getChannelIcon(item)"
[alt]="getChannelName(item)"
style="height: 20px; width: 20px; margin-right: 8px;"
>
}
<span>{{ item.name }}</span>
<span>{{ getChannelName(item) }}</span>
</div>
</foreignObject>
</g>
@@ -82,7 +82,7 @@
}
</svg>
<div id="epg-container">
<svg [attr.width]="(hourWidth$ | async) * 24" height="100%" id="epg-svg">
<svg [attr.width]="(hourWidth$ | async)! * 24" height="100%" id="epg-svg">
<!-- time headline -->
@for (hour of timeHeader; track hour) {
<g>
@@ -90,11 +90,11 @@
[matTooltip]="hour + ':00'"
[attr.width]="hourWidth$ | async"
[attr.height]="barHeight"
[attr.x]="hour * (hourWidth$ | async)"
[attr.x]="hour * (hourWidth$ | async)!"
fill="#000"
></rect>
<text
[attr.x]="hour * (hourWidth$ | async)"
[attr.x]="hour * (hourWidth$ | async)!"
y="10"
font-size="14"
fill="white"
@@ -106,7 +106,7 @@
}
<!-- epg channels with programs -->
@for (item of channels$ | async; track $any(item).channel; let i = $index) {
@for (item of channels$ | async; track item.id; let i = $index) {
<g>
@for (program of item.programs; track trackByProgram($index, program)) {
<g
@@ -123,7 +123,7 @@
[attr.width]="program.width"
[attr.height]="barHeight"
>
<div class="program-title">{{ program.title }}</div>
<div class="program-title">{{ program.title[0]?.value }}</div>
</foreignObject>
</g>
}
@@ -135,7 +135,7 @@
[attr.x1]="currentTimeLine"
y1="0"
[attr.x2]="currentTimeLine"
[attr.y2]="(channels$ | async)?.length * barHeight + barHeight"
[attr.y2]="(channels$ | async)!.length * barHeight + barHeight"
stroke="red"
stroke-width="2"
></line>
@@ -18,7 +18,6 @@ import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { invoke } from '@tauri-apps/api/core';
import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns';
import { BehaviorSubject, Observable } from 'rxjs';
import {
@@ -154,29 +153,22 @@ export class MultiEpgContainerComponent
}
async requestPrograms(): Promise<void> {
const today = new Date();
const startTime = format(subDays(today, 1), 'yyyyMMddHHmmss +0000');
const endTime = format(addDays(today, 2), 'yyyyMMddHHmmss +0000');
if (!window.electron) {
console.warn('Multi-EPG not available: Electron not detected');
return;
}
try {
const channelNames = this._playlistChannels
.map((channel) => channel.name?.trim() ?? '')
.filter((name) => name !== '');
console.log('Requesting EPG data:');
console.log('- Skip:', this.channelsLowerRange);
console.log('- Limit:', this.visibleChannels);
console.log('- Channel names count:', channelNames.length);
const response = await invoke<any>('get_epg_by_range', {
startTime,
endTime,
skip: this.channelsLowerRange,
limit: this.visibleChannels,
playlistChannelNames: channelNames,
});
const response = await window.electron.getEpgChannelsByRange(
this.channelsLowerRange,
this.visibleChannels
);
if (response) {
if (response && Array.isArray(response)) {
console.log('Received channels:', response.length);
this.originalEpgData = response;
this.channels$.next(this.enrichProgramData());
@@ -223,14 +215,14 @@ export class MultiEpgContainerComponent
return this.originalEpgData.map((channel) => {
const filteredPrograms = channel.programs
.filter((item) => {
.filter((item: EpgProgram) => {
const itemDate = format(
this.getCachedDate(item.start),
'yyyyMMdd'
);
return itemDate === this.today;
})
.map((program) => {
.map((program: EpgProgram) => {
const startDate = this.getCachedDate(program.start);
const stopDate = this.getCachedDate(program.stop);
const startPosition =
@@ -255,6 +247,26 @@ export class MultiEpgContainerComponent
});
}
/**
* Get display name from EpgChannel
*/
getChannelName(channel: EpgChannel): string {
if (channel.displayName && channel.displayName.length > 0) {
return channel.displayName[0].value;
}
return '';
}
/**
* Get icon from EpgChannel
*/
getChannelIcon(channel: EpgChannel): string {
if (channel.icon && channel.icon.length > 0) {
return channel.icon[0].src;
}
return '';
}
zoomIn(): void {
if (this.hourWidth$.value >= 800) return;
this.hourWidth$.next(this.hourWidth$.value + 50);