mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: add channel metadata resolution and logo handling for enriched channel views
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
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;
|
||||
}
|
||||
+1
-1
@@ -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'"
|
||||
|
||||
+44
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -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;
|
||||
});
|
||||
|
||||
+4
@@ -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()"
|
||||
|
||||
+3
@@ -55,6 +55,9 @@ describe('ChannelListContainerComponent', () => {
|
||||
{
|
||||
provide: EpgService,
|
||||
useValue: {
|
||||
getChannelMetadataForChannels: jest
|
||||
.fn()
|
||||
.mockReturnValue(of(new Map())),
|
||||
getCurrentProgramsForChannels: jest
|
||||
.fn()
|
||||
.mockReturnValue(of(new Map())),
|
||||
|
||||
+27
-8
@@ -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() || '',
|
||||
])
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
@@ -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 || '';
|
||||
}
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+6
-1
@@ -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;
|
||||
});
|
||||
|
||||
+1
-1
@@ -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'"
|
||||
|
||||
+31
@@ -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');
|
||||
|
||||
|
||||
+6
-1
@@ -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;
|
||||
});
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user