mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -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
+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