diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index f6e91df42..c22da1104 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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 }), diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index 20c2576f6..ee0f84ee0 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -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)[ + '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(); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index 9707059c0..ee8000e6d 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -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> { + 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 */ diff --git a/libs/shared/interfaces/src/lib/epg-channel-metadata.model.ts b/libs/shared/interfaces/src/lib/epg-channel-metadata.model.ts new file mode 100644 index 000000000..aed69ac11 --- /dev/null +++ b/libs/shared/interfaces/src/lib/epg-channel-metadata.model.ts @@ -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; +} diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index 5bab3411e..91c49a695 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -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'" diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts index 81fb57d50..61d263f01 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts @@ -71,6 +71,7 @@ describe('AllChannelsViewComponent', () => { fixture.componentRef.setInput('channels', [primaryChannel]); fixture.componentRef.setInput('searchTerm', ''); fixture.componentRef.setInput('channelEpgMap', new Map()); + fixture.componentRef.setInput('channelIconMap', new Map()); 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' + ); + }); }); diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts index ee2131328..c141cb2d3 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts @@ -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>(); + readonly channelIconMap = input.required>(); /** Progress tick to trigger progress recalculation */ readonly progressTick = input.required(); @@ -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; }); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index e7046172d..a034e2e24 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -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()" diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts index 087a5fa44..44e20543d 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -55,6 +55,9 @@ describe('ChannelListContainerComponent', () => { { provide: EpgService, useValue: { + getChannelMetadataForChannels: jest + .fn() + .mockReturnValue(of(new Map())), getCurrentProgramsForChannels: jest .fn() .mockReturnValue(of(new Map())), diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 07abe3242..49f145458 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -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()); + readonly channelIconMap = signal(new Map()); /** 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() || '', + ]) + ) + ); + }); } /** diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts index 32bb99dd8..7d23cccb6 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -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); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-logo-fallback.util.ts b/libs/ui/components/src/lib/channel-list-container/channel-logo-fallback.util.ts new file mode 100644 index 000000000..5b375b702 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/channel-logo-fallback.util.ts @@ -0,0 +1,17 @@ +import { resolveChannelEpgLookupKey } from 'm3u-state'; +import { Channel } from 'shared-interfaces'; + +export function resolveChannelLogo( + channel: Channel | null | undefined, + channelIconMap: ReadonlyMap +): string { + const playlistLogo = channel?.tvg?.logo?.trim(); + if (playlistLogo) { + return playlistLogo; + } + + const channelId = resolveChannelEpgLookupKey(channel); + const epgIcon = channelId ? channelIconMap.get(channelId)?.trim() : ''; + + return epgIcon || ''; +} diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html index be223b8fd..8d8c15a28 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html @@ -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" diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts index 554d3ff7d..fe4b8c111 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts @@ -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>(); + readonly channelIconMap = input.required>(); /** Progress tick to trigger progress recalculation */ readonly progressTick = input.required(); @@ -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; }); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index 8f43d2177..ea0971fad 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -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'" diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index a6fd7e050..78a3cf7d6 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -161,6 +161,10 @@ describe('GroupsViewComponent', () => { ); fixture.componentRef.setInput('searchTerm', overrides.searchTerm ?? ''); fixture.componentRef.setInput('channelEpgMap', new Map()); + fixture.componentRef.setInput( + 'channelIconMap', + new Map() + ); 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'); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 571ef496d..5b5820397 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -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>(); + readonly channelIconMap = input.required>(); /** Progress tick to trigger progress recalculation */ readonly progressTick = input.required(); @@ -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; }); diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html index d8963f402..80ca6cea9 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html @@ -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" diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts index 12efab0a4..09182a12f 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts @@ -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(); readonly searchTerm = input(''); readonly channelEpgMap = input.required>(); + readonly channelIconMap = input.required>(); readonly progressTick = input.required(); readonly shouldShowEpg = input.required(); readonly activeChannelUrl = input(); @@ -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), }; });