feat: add channel metadata resolution and logo handling for enriched channel views

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-20 00:23:32 +02:00
1 parent 40d2611ef3
commit c13dcc59dd
19 files changed
+331 -17

No files matched your search

@@ -26,7 +26,7 @@
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="channel?.tvg?.logo"
[logo]="channel.logo"
[showEpg]="shouldShowEpg()"
[showDetailsContextMenu]="true"
[isRadio]="channel?.radio === 'true'"
@@ -71,6 +71,7 @@ describe('AllChannelsViewComponent', () => {
fixture.componentRef.setInput('channels', [primaryChannel]);
fixture.componentRef.setInput('searchTerm', '');
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
fixture.componentRef.setInput('channelIconMap', new Map<string, string>());
fixture.componentRef.setInput('progressTick', 0);
fixture.componentRef.setInput('shouldShowEpg', false);
fixture.componentRef.setInput('itemSize', 48);
@@ -110,4 +111,47 @@ describe('AllChannelsViewComponent', () => {
})
);
});
it('keeps the playlist logo when an EPG icon is also available', () => {
const playlistLogo = 'https://example.com/playlist-logo.png';
const channel = {
...primaryChannel,
tvg: {
...primaryChannel.tvg,
logo: playlistLogo,
},
};
fixture.componentRef.setInput('channels', [channel]);
fixture.componentRef.setInput(
'channelIconMap',
new Map([[channel.tvg.id, 'https://example.com/epg-logo.png']])
);
fixture.detectChanges();
expect(component.enrichedChannels()[0].logo).toBe(playlistLogo);
});
it('falls back to the EPG icon using tvg-name when tvg-id and playlist logo are missing', () => {
const channel = {
...primaryChannel,
tvg: {
...primaryChannel.tvg,
id: '',
logo: '',
name: 'Guide News',
},
};
fixture.componentRef.setInput('channels', [channel]);
fixture.componentRef.setInput(
'channelIconMap',
new Map([['Guide News', 'https://example.com/guide-news.png']])
);
fixture.detectChanges();
expect(component.enrichedChannels()[0].logo).toBe(
'https://example.com/guide-news.png'
);
});
});
@@ -14,13 +14,16 @@ 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 { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
/** Enriched channel with pre-computed EPG and progress data */
export interface EnrichedChannel extends Channel {
epgProgram: EpgProgram | null | undefined;
logo: string;
progressPercentage: number;
}
@@ -50,6 +53,7 @@ export class AllChannelsViewComponent {
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly channelIconMap = input.required<Map<string, string>>();
/** Progress tick to trigger progress recalculation */
readonly progressTick = input.required<number>();
@@ -88,6 +92,7 @@ export class AllChannelsViewComponent {
const term = this.searchTerm().trim().toLowerCase();
const channels = this.channels();
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
// Read progressTick to create dependency for progress refresh
this.progressTick();
@@ -102,11 +107,12 @@ export class AllChannelsViewComponent {
// Enrich with EPG data and pre-calculate progress
return result.map((channel) => {
const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim();
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
...channel,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
} as EnrichedChannel;
});
@@ -24,6 +24,7 @@
[channels]="displayedChannels()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[channelIconMap]="channelIconMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[itemSize]="itemSize()"
@@ -38,6 +39,7 @@
[groupedChannels]="groupedChannels()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[channelIconMap]="channelIconMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
@@ -60,6 +62,7 @@
[favorites]="favorites"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[channelIconMap]="channelIconMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
@@ -74,6 +77,7 @@
[recentItems]="recentChannelItems()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[channelIconMap]="channelIconMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
@@ -55,6 +55,9 @@ describe('ChannelListContainerComponent', () => {
{
provide: EpgService,
useValue: {
getChannelMetadataForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
getCurrentProgramsForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
@@ -28,6 +28,7 @@ import {
ChannelActions,
FavoritesActions,
PlaylistActions,
resolveChannelEpgLookupKey,
selectActive,
selectFavorites,
} from 'm3u-state';
@@ -35,6 +36,7 @@ import {
BehaviorSubject,
combineLatest,
filter,
forkJoin,
firstValueFrom,
map,
} from 'rxjs';
@@ -97,6 +99,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Map of channel ID to current EPG program */
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
readonly channelIconMap = signal(new Map<string, string>());
/** Interval for refreshing EPG data */
private epgRefreshInterval?: number;
@@ -312,18 +315,34 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
*/
private fetchEpgForChannels(channels: Channel[]): void {
if (!channels || channels.length === 0) {
this.channelEpgMap.set(new Map());
this.channelIconMap.set(new Map());
return;
}
const channelIds = channels
.map((channel) => channel?.tvg?.id?.trim() || channel?.name?.trim())
.filter((id) => !!id);
const channelIds = Array.from(
new Set(
channels
.map((channel) => resolveChannelEpgLookupKey(channel))
.filter((id) => !!id)
)
);
this.epgService
.getCurrentProgramsForChannels(channelIds)
.subscribe((epgMap) => {
this.channelEpgMap.set(epgMap);
});
forkJoin({
epgMap: this.epgService.getCurrentProgramsForChannels(channelIds),
metadataMap:
this.epgService.getChannelMetadataForChannels(channelIds),
}).subscribe(({ epgMap, metadataMap }) => {
this.channelEpgMap.set(epgMap);
this.channelIconMap.set(
new Map(
Array.from(metadataMap.entries(), ([channelId, metadata]) => [
channelId,
metadata?.iconUrl?.trim() || '',
])
)
);
});
}
/**
@@ -78,6 +78,17 @@ describe('ChannelListItemComponent', () => {
).toBeNull();
});
it('shows the generic fallback icon when no logo is available', () => {
fixture.componentRef.setInput('name', 'Channel Without Logo');
fixture.componentRef.setInput('logo', '');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.channel-logo-fallback')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.channel-logo')).toBeNull();
});
it('emits a context menu request on right click when details are enabled', () => {
fixture.componentRef.setInput('name', 'News One');
fixture.componentRef.setInput('showDetailsContextMenu', true);
@@ -0,0 +1,17 @@
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel } from 'shared-interfaces';
export function resolveChannelLogo(
channel: Channel | null | undefined,
channelIconMap: ReadonlyMap<string, string | null | undefined>
): string {
const playlistLogo = channel?.tvg?.logo?.trim();
if (playlistLogo) {
return playlistLogo;
}
const channelId = resolveChannelEpgLookupKey(channel);
const epgIcon = channelId ? channelIconMap.get(channelId)?.trim() : '';
return epgIcon || '';
}
@@ -19,7 +19,7 @@
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[isDraggable]="!hasSearchTerm()"
[logo]="channel?.tvg?.logo"
[logo]="channel.logo"
[showEpg]="shouldShowEpg()"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="channel.epgProgram"
@@ -11,8 +11,10 @@ import {
output,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import { EnrichedChannel } from '../all-channels-view/all-channels-view.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@Component({
@@ -29,6 +31,7 @@ export class FavoritesViewComponent {
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly channelIconMap = input.required<Map<string, string>>();
/** Progress tick to trigger progress recalculation */
readonly progressTick = input.required<number>();
@@ -73,15 +76,17 @@ export class FavoritesViewComponent {
readonly enrichedFavorites = computed(() => {
const favorites = this.filteredFavorites();
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
// Read progressTick to trigger recalculation
this.progressTick();
return favorites.map((channel) => {
const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim();
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
...channel,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
} as EnrichedChannel;
});
@@ -221,7 +221,7 @@
(channel?.name ||
('CHANNELS.UNNAMED_CHANNEL' | translate))
"
[logo]="channel?.tvg?.logo"
[logo]="channel.logo"
[showEpg]="shouldShowEpg()"
[showDetailsContextMenu]="true"
[isRadio]="channel?.radio === 'true'"
@@ -161,6 +161,10 @@ describe('GroupsViewComponent', () => {
);
fixture.componentRef.setInput('searchTerm', overrides.searchTerm ?? '');
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
fixture.componentRef.setInput(
'channelIconMap',
new Map<string, string>()
);
fixture.componentRef.setInput(
'progressTick',
overrides.progressTick ?? 0
@@ -224,6 +228,33 @@ describe('GroupsViewComponent', () => {
expect(component.groupChannelSortMode()).toBe('server');
});
it('falls back to an EPG icon for grouped channels without a playlist logo', () => {
const channelWithoutLogo = {
...sportsCenter,
tvg: {
...sportsCenter.tvg,
logo: '',
},
};
setInputs({
groupedChannels: {
Sports: [channelWithoutLogo],
},
});
fixture.componentRef.setInput(
'channelIconMap',
new Map([
[channelWithoutLogo.tvg.id, 'https://example.com/sports.png'],
])
);
fixture.detectChanges();
expect(component.selectedGroupChannels()[0].logo).toBe(
'https://example.com/sports.png'
);
});
it('persists sort mode changes', () => {
component.setGroupChannelSortMode('name-desc');
@@ -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 { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import {
PortalChannelSortMode,
@@ -28,6 +29,7 @@ import {
sortPortalChannelItems,
} from '@iptvnator/portal/shared/util';
import { EnrichedChannel } from '../all-channels-view/all-channels-view.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
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';
@@ -83,6 +85,7 @@ export class GroupsViewComponent {
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly channelIconMap = input.required<Map<string, string>>();
/** Progress tick to trigger progress recalculation */
readonly progressTick = input.required<number>();
@@ -315,6 +318,7 @@ export class GroupsViewComponent {
const group = this.selectedGroup();
const sortMode = this.groupChannelSortMode();
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
this.progressTick();
if (!group) {
@@ -326,11 +330,12 @@ export class GroupsViewComponent {
sortMode,
(channel) => channel?.name
).map((channel) => {
const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim();
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
...channel,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
} as EnrichedChannel;
});
@@ -12,7 +12,7 @@
'. ' +
(item.channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="item.channel?.tvg?.logo"
[logo]="item.logo"
[showEpg]="shouldShowEpg()"
[isRadio]="item.channel?.radio === 'true'"
[epgProgram]="item.epgProgram"
@@ -6,7 +6,9 @@ import {
output,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
export interface RecentViewItem {
@@ -25,6 +27,7 @@ export class RecentViewComponent {
readonly recentItems = input.required<RecentViewItem[]>();
readonly searchTerm = input('');
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly channelIconMap = input.required<Map<string, string>>();
readonly progressTick = input.required<number>();
readonly shouldShowEpg = input.required<boolean>();
readonly activeChannelUrl = input<string | undefined>();
@@ -50,16 +53,18 @@ export class RecentViewComponent {
readonly enrichedRecentItems = computed(() => {
const recentItems = this.filteredRecentItems();
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
this.progressTick();
return recentItems.map(({ channel, viewedAt }) => {
const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim();
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
channel,
viewedAt,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
};
});