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

@@ -338,6 +338,8 @@ const electronApi = {
ipcRenderer.invoke('FETCH_EPG', { url: urls }),
getChannelPrograms: (channelId: string) =>
ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }),
getEpgChannelMetadata: (channelIds: string[]) =>
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds }),
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
getEpgChannelsByRange: (skip: number, limit: number) =>
ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }),
@@ -186,6 +186,48 @@ describe('EpgEvents', () => {
expect(programs[0].channel).toBe('BBC.ONE.UK');
});
it('resolves channel metadata using exact id, case-insensitive id, and display name fallback', async () => {
const where = jest.fn().mockResolvedValue([
{
id: 'BBC.ONE.UK',
displayName: 'BBC One',
iconUrl: 'https://example.com/bbc-one.png',
},
{
id: 'guide-news',
displayName: 'Guide News',
iconUrl: 'https://example.com/guide-news.png',
},
]);
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
getDatabase.mockResolvedValue({ select });
const metadata = await (EpgEvents as unknown as Record<string, any>)[
'handleGetChannelMetadata'
](['BBC.ONE.UK', 'bbc.one.uk', 'Guide News', 'Missing Channel']);
expect(metadata).toEqual({
'BBC.ONE.UK': {
id: 'BBC.ONE.UK',
displayName: 'BBC One',
iconUrl: 'https://example.com/bbc-one.png',
},
'bbc.one.uk': {
id: 'BBC.ONE.UK',
displayName: 'BBC One',
iconUrl: 'https://example.com/bbc-one.png',
},
'Guide News': {
id: 'guide-news',
displayName: 'Guide News',
iconUrl: 'https://example.com/guide-news.png',
},
'Missing Channel': null,
});
});
it('drops malformed EPG rows with invalid stop dates', async () => {
const select = jest.fn();
const from = jest.fn();
@@ -1,7 +1,7 @@
import { eq, sql } from 'drizzle-orm';
import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'path';
import { EpgProgram } from 'shared-interfaces';
import { EpgChannelMetadata, EpgProgram } from 'shared-interfaces';
import { pathToFileURL } from 'url';
import { Worker } from 'worker_threads';
import { getDatabase } from '../database/connection';
@@ -86,6 +86,13 @@ export default class EpgEvents {
return this.handleGetAllChannels();
});
ipcMain.handle(
'EPG_GET_CHANNEL_METADATA',
async (_event, args: { channelIds: string[] }) => {
return this.handleGetChannelMetadata(args.channelIds);
}
);
// Get channels by range (pagination)
ipcMain.handle(
'EPG_GET_CHANNELS_BY_RANGE',
@@ -450,6 +457,52 @@ export default class EpgEvents {
});
}
private static normalizeChannelLookupKeys(channelIds: string[]): string[] {
return Array.from(
new Set(
channelIds
.map((channelId) => channelId.trim())
.filter((channelId) => channelId.length > 0)
)
);
}
private static resolveChannelMetadataCandidate(
channelId: string,
candidates: EpgChannelMetadata[]
): EpgChannelMetadata | null {
const lowerChannelId = channelId.toLowerCase();
const exactIdMatch =
candidates.find((candidate) => candidate.id === channelId) ?? null;
if (exactIdMatch) {
return exactIdMatch;
}
const caseInsensitiveIdMatch =
candidates.find(
(candidate) => candidate.id.toLowerCase() === lowerChannelId
) ?? null;
if (caseInsensitiveIdMatch) {
return caseInsensitiveIdMatch;
}
const exactDisplayNameMatch =
candidates.find(
(candidate) => candidate.displayName === channelId
) ?? null;
if (exactDisplayNameMatch) {
return exactDisplayNameMatch;
}
return (
candidates.find(
(candidate) =>
candidate.displayName.toLowerCase() === lowerChannelId
) ?? null
);
}
/**
* Transform database row to flat EpgProgram interface
*/
@@ -610,6 +663,64 @@ export default class EpgEvents {
}
}
/**
* Resolve EPG channel metadata for a batch of lookup keys.
*
* Lookup precedence per key:
* 1. exact channel id
* 2. case-insensitive channel id
* 3. exact display name
* 4. case-insensitive display name
*/
private static async handleGetChannelMetadata(
channelIds: string[]
): Promise<Record<string, EpgChannelMetadata | null>> {
try {
const normalizedChannelIds =
this.normalizeChannelLookupKeys(channelIds);
if (normalizedChannelIds.length === 0) {
return {};
}
const db = await getDatabase();
const lowerKeys = Array.from(
new Set(
normalizedChannelIds.map((channelId) =>
channelId.toLowerCase()
)
)
);
const lowerKeyValues = lowerKeys.map((key) => sql`${key}`);
const candidates = await db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels)
.where(sql`
LOWER(${schema.epgChannels.id}) IN (${sql.join(lowerKeyValues, sql`, `)})
OR LOWER(${schema.epgChannels.displayName}) IN (${sql.join(lowerKeyValues, sql`, `)})
`);
return Object.fromEntries(
normalizedChannelIds.map((channelId) => [
channelId,
this.resolveChannelMetadataCandidate(channelId, candidates),
])
);
} catch (error) {
console.error(
this.loggerLabel,
'Error getting channel metadata:',
error
);
return {};
}
}
/**
* Get channels by range (for pagination) with their programs
*/
@@ -0,0 +1,9 @@
/**
* Flattened EPG channel metadata returned to the renderer for lookup-based
* enrichment of playlist channels.
*/
export interface EpgChannelMetadata {
id: string;
displayName: string;
iconUrl: string | null;
}
@@ -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),
};
});