580 lines
19 KiB
TypeScript
580 lines
19 KiB
TypeScript
import {
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
Directive,
|
|
input,
|
|
output,
|
|
signal,
|
|
} from '@angular/core';
|
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
import { TranslateModule } from '@ngx-translate/core';
|
|
import {
|
|
GlobalFavoritesListComponent,
|
|
UnifiedLiveTabComponent,
|
|
} from '@iptvnator/portal/shared/ui';
|
|
import {
|
|
AudioPlayerComponent,
|
|
ArtPlayerComponent,
|
|
HtmlVideoPlayerComponent,
|
|
VjsPlayerComponent,
|
|
} from '@iptvnator/ui/playback';
|
|
import { EpgListComponent } from '@iptvnator/ui/epg';
|
|
import { ResizableDirective } from 'components';
|
|
import { SettingsStore } from 'services';
|
|
import { Channel, EpgItem, EpgProgram } from 'shared-interfaces';
|
|
import { EpgViewComponent } from 'shared-portals';
|
|
import {
|
|
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
|
|
PORTAL_PLAYER,
|
|
FavoritesChannelSortMode,
|
|
StreamResolverService,
|
|
UnifiedCollectionItem,
|
|
UnifiedFavoriteChannel,
|
|
UnifiedRecentDataService,
|
|
} from '@iptvnator/portal/shared/util';
|
|
|
|
@Directive({
|
|
selector: '[appResizable]',
|
|
})
|
|
class StubResizableDirective {
|
|
readonly minWidth = input<number>();
|
|
readonly maxWidth = input<number>();
|
|
readonly defaultWidth = input<number>();
|
|
readonly storageKey = input<string>('');
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-global-favorites-list',
|
|
template: '',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
class StubGlobalFavoritesListComponent {
|
|
readonly channels = input.required<UnifiedFavoriteChannel[]>();
|
|
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
|
|
readonly progressTick = input(0);
|
|
readonly activeUid = input<string | null>(null);
|
|
readonly searchTermInput = input('');
|
|
readonly draggable = input(true);
|
|
readonly sortMode = input<FavoritesChannelSortMode>(
|
|
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
|
|
);
|
|
|
|
readonly channelSelected = output<UnifiedFavoriteChannel>();
|
|
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
|
|
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-epg-list',
|
|
template: '<div class="stub-epg-list"></div>',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
class StubEpgListComponent {
|
|
readonly controlledChannel = input<Channel | null>(null);
|
|
readonly controlledPrograms = input<EpgProgram[] | null>(null);
|
|
readonly archivePlaybackAvailable = input<boolean | null>(null);
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-epg-view',
|
|
template: '<div class="stub-epg-view"></div>',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
class StubEpgViewComponent {
|
|
readonly epgItems = input<EpgItem[]>([]);
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-vjs-player',
|
|
template: '',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
class StubVjsPlayerComponent {
|
|
readonly options = input<unknown>();
|
|
readonly volume = input(1);
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-html-video-player',
|
|
template: '',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
class StubHtmlVideoPlayerComponent {
|
|
readonly channel = input<Channel | null>(null);
|
|
readonly volume = input(1);
|
|
readonly showCaptions = input(false);
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-art-player',
|
|
template: '',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
class StubArtPlayerComponent {
|
|
readonly channel = input<Channel | null>(null);
|
|
readonly volume = input(1);
|
|
readonly showCaptions = input(false);
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-audio-player',
|
|
template: '<div class="stub-audio-player"></div>',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
class StubAudioPlayerComponent {
|
|
readonly icon = input('');
|
|
readonly url = input.required<string>();
|
|
readonly channelName = input('');
|
|
}
|
|
|
|
describe('UnifiedLiveTabComponent', () => {
|
|
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
|
|
let component: UnifiedLiveTabComponent;
|
|
let streamResolver: {
|
|
resolveLiveDetail: jest.Mock;
|
|
resolveM3uPlaybackDetail: jest.Mock;
|
|
loadM3uProgramsForItem: jest.Mock;
|
|
loadEpgForItems: jest.Mock;
|
|
};
|
|
let recentData: {
|
|
recordLivePlayback: jest.Mock;
|
|
};
|
|
let portalPlayer: {
|
|
isEmbeddedPlayer: jest.Mock;
|
|
openResolvedPlayback: jest.Mock;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
streamResolver = {
|
|
resolveLiveDetail: jest.fn(),
|
|
resolveM3uPlaybackDetail: jest.fn(),
|
|
loadM3uProgramsForItem: jest.fn().mockResolvedValue([]),
|
|
loadEpgForItems: jest.fn().mockResolvedValue(new Map()),
|
|
};
|
|
recentData = {
|
|
recordLivePlayback: jest.fn(),
|
|
};
|
|
portalPlayer = {
|
|
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
|
openResolvedPlayback: jest.fn(),
|
|
};
|
|
|
|
await TestBed.configureTestingModule({
|
|
imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent],
|
|
providers: [
|
|
{ provide: StreamResolverService, useValue: streamResolver },
|
|
{ provide: UnifiedRecentDataService, useValue: recentData },
|
|
{
|
|
provide: SettingsStore,
|
|
useValue: {
|
|
player: signal('videojs'),
|
|
},
|
|
},
|
|
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
|
],
|
|
})
|
|
.overrideComponent(UnifiedLiveTabComponent, {
|
|
remove: {
|
|
imports: [
|
|
AudioPlayerComponent,
|
|
ArtPlayerComponent,
|
|
EpgListComponent,
|
|
EpgViewComponent,
|
|
GlobalFavoritesListComponent,
|
|
HtmlVideoPlayerComponent,
|
|
ResizableDirective,
|
|
VjsPlayerComponent,
|
|
],
|
|
},
|
|
add: {
|
|
imports: [
|
|
StubAudioPlayerComponent,
|
|
StubArtPlayerComponent,
|
|
StubEpgListComponent,
|
|
StubEpgViewComponent,
|
|
StubGlobalFavoritesListComponent,
|
|
StubHtmlVideoPlayerComponent,
|
|
StubResizableDirective,
|
|
StubVjsPlayerComponent,
|
|
],
|
|
},
|
|
})
|
|
.compileComponents();
|
|
|
|
fixture = TestBed.createComponent(UnifiedLiveTabComponent);
|
|
component = fixture.componentInstance;
|
|
});
|
|
|
|
it('renders controlled M3U EPG and records recent history on selection', async () => {
|
|
const item = buildLiveItem('m3u');
|
|
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
|
|
epgMode: 'm3u',
|
|
playback: {
|
|
streamUrl: 'https://example.com/m3u.m3u8',
|
|
title: 'M3U Live',
|
|
},
|
|
channel: {
|
|
id: 'm3u-channel',
|
|
name: 'M3U Live',
|
|
url: 'https://example.com/m3u.m3u8',
|
|
group: { title: 'News' },
|
|
tvg: {
|
|
id: 'm3u-channel',
|
|
name: 'M3U Live',
|
|
url: '',
|
|
logo: 'm3u.png',
|
|
rec: '',
|
|
},
|
|
http: { referrer: '', 'user-agent': '', origin: '' },
|
|
radio: 'false',
|
|
epgParams: '',
|
|
},
|
|
epgPrograms: [],
|
|
});
|
|
streamResolver.loadM3uProgramsForItem.mockResolvedValue([
|
|
buildProgram('M3U Show'),
|
|
]);
|
|
recentData.recordLivePlayback.mockResolvedValue({
|
|
...item,
|
|
viewedAt: '2026-03-26T12:00:00.000Z',
|
|
});
|
|
|
|
fixture.componentRef.setInput('items', [item]);
|
|
fixture.componentRef.setInput('mode', 'recent');
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
|
|
await component.onChannelSelected(component.channelsForList()[0]);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
|
|
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
|
expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalledWith(
|
|
item,
|
|
expect.objectContaining({
|
|
id: 'm3u-channel',
|
|
})
|
|
);
|
|
expect(
|
|
fixture.nativeElement.querySelector('app-epg-list')
|
|
).not.toBeNull();
|
|
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
|
|
});
|
|
|
|
it('does not wait for M3U program lookup before opening playback', async () => {
|
|
const item = buildLiveItem('m3u');
|
|
const pendingPrograms = createDeferred<EpgProgram[]>();
|
|
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
|
|
epgMode: 'm3u',
|
|
playback: {
|
|
streamUrl: 'https://example.com/m3u.m3u8',
|
|
title: 'M3U Live',
|
|
},
|
|
channel: {
|
|
id: 'm3u-channel',
|
|
name: 'M3U Live',
|
|
url: 'https://example.com/m3u.m3u8',
|
|
group: { title: 'News' },
|
|
tvg: {
|
|
id: 'm3u-channel',
|
|
name: 'M3U Live',
|
|
url: '',
|
|
logo: 'm3u.png',
|
|
rec: '',
|
|
},
|
|
http: { referrer: '', 'user-agent': '', origin: '' },
|
|
radio: 'false',
|
|
epgParams: '',
|
|
},
|
|
epgPrograms: [buildProgram('M3U Show')],
|
|
});
|
|
streamResolver.loadM3uProgramsForItem.mockReturnValue(
|
|
pendingPrograms.promise
|
|
);
|
|
recentData.recordLivePlayback.mockResolvedValue({
|
|
...item,
|
|
viewedAt: '2026-03-26T12:00:00.000Z',
|
|
});
|
|
|
|
fixture.componentRef.setInput('items', [item]);
|
|
fixture.componentRef.setInput('mode', 'recent');
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
|
|
await component.onChannelSelected(component.channelsForList()[0]);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
|
|
expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
streamUrl: 'https://example.com/m3u.m3u8',
|
|
})
|
|
);
|
|
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
|
expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalled();
|
|
|
|
pendingPrograms.resolve([buildProgram('M3U Show')]);
|
|
await fixture.whenStable();
|
|
});
|
|
|
|
it('renders inline audio for M3U radio items and skips external playback', async () => {
|
|
const item = {
|
|
...buildLiveItem('m3u'),
|
|
radio: 'true',
|
|
} satisfies UnifiedCollectionItem;
|
|
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
|
|
epgMode: 'm3u',
|
|
playback: {
|
|
streamUrl: 'https://example.com/radio.m3u8',
|
|
title: 'M3U Radio',
|
|
thumbnail: 'radio.png',
|
|
},
|
|
channel: {
|
|
id: 'm3u-channel',
|
|
name: 'M3U Radio',
|
|
url: 'https://example.com/radio.m3u8',
|
|
group: { title: 'Radio' },
|
|
tvg: {
|
|
id: 'm3u-channel',
|
|
name: 'M3U Radio',
|
|
url: '',
|
|
logo: 'radio.png',
|
|
rec: '',
|
|
},
|
|
http: { referrer: '', 'user-agent': '', origin: '' },
|
|
radio: 'true',
|
|
epgParams: '',
|
|
},
|
|
epgPrograms: [],
|
|
});
|
|
recentData.recordLivePlayback.mockResolvedValue({
|
|
...item,
|
|
viewedAt: '2026-03-26T12:00:00.000Z',
|
|
});
|
|
|
|
fixture.componentRef.setInput('items', [item]);
|
|
fixture.componentRef.setInput('mode', 'recent');
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
|
|
await component.onChannelSelected(component.channelsForList()[0]);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
|
|
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
|
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
|
|
expect(
|
|
fixture.nativeElement.querySelector('app-audio-player')
|
|
).not.toBeNull();
|
|
expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull();
|
|
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
|
|
});
|
|
|
|
it('renders shared EPG view for Xtream items and records recent history', async () => {
|
|
const item = buildLiveItem('xtream');
|
|
streamResolver.resolveLiveDetail.mockResolvedValue({
|
|
epgMode: 'portal',
|
|
playback: {
|
|
streamUrl: 'https://example.com/xtream.m3u8',
|
|
title: 'Xtream Live',
|
|
},
|
|
epgItems: [buildEpgItem('Xtream Show')],
|
|
});
|
|
recentData.recordLivePlayback.mockResolvedValue({
|
|
...item,
|
|
viewedAt: '2026-03-26T12:00:00.000Z',
|
|
});
|
|
|
|
fixture.componentRef.setInput('items', [item]);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
|
|
await component.onChannelSelected(component.channelsForList()[0]);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
|
|
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
|
expect(
|
|
fixture.nativeElement.querySelector('app-epg-view')
|
|
).not.toBeNull();
|
|
expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull();
|
|
});
|
|
|
|
it('renders shared EPG view for Stalker items and records recent history', async () => {
|
|
const item = buildLiveItem('stalker');
|
|
streamResolver.resolveLiveDetail.mockResolvedValue({
|
|
epgMode: 'portal',
|
|
playback: {
|
|
streamUrl: 'https://example.com/stalker.m3u8',
|
|
title: 'Stalker Live',
|
|
},
|
|
epgItems: [buildEpgItem('Stalker Show')],
|
|
});
|
|
recentData.recordLivePlayback.mockResolvedValue({
|
|
...item,
|
|
viewedAt: '2026-03-26T12:00:00.000Z',
|
|
});
|
|
|
|
fixture.componentRef.setInput('items', [item]);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
|
|
await component.onChannelSelected(component.channelsForList()[0]);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
|
|
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
|
expect(
|
|
fixture.nativeElement.querySelector('app-epg-view')
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it('does not restart auto-open while the same live item is still resolving', async () => {
|
|
const item = buildLiveItem('xtream');
|
|
const pendingDetail = createDeferred<{
|
|
epgMode: 'portal';
|
|
playback: {
|
|
streamUrl: string;
|
|
title: string;
|
|
};
|
|
epgItems: EpgItem[];
|
|
}>();
|
|
const autoOpenHandledSpy = jest.spyOn(
|
|
component.autoOpenHandled,
|
|
'emit'
|
|
);
|
|
|
|
streamResolver.resolveLiveDetail.mockReturnValue(pendingDetail.promise);
|
|
recentData.recordLivePlayback.mockResolvedValue({
|
|
...item,
|
|
viewedAt: '2026-03-26T12:00:00.000Z',
|
|
});
|
|
|
|
fixture.componentRef.setInput('items', [item]);
|
|
fixture.componentRef.setInput('autoOpenItem', {
|
|
contentType: 'live',
|
|
sourceType: 'xtream',
|
|
playlistId: item.playlistId,
|
|
itemId: String(item.xtreamId),
|
|
title: item.name,
|
|
imageUrl: item.logo,
|
|
});
|
|
fixture.detectChanges();
|
|
await Promise.resolve();
|
|
|
|
expect(streamResolver.resolveLiveDetail).toHaveBeenCalledTimes(1);
|
|
expect(component.isSelecting()).toBe(true);
|
|
|
|
fixture.componentRef.setInput('items', [{ ...item }]);
|
|
fixture.detectChanges();
|
|
await Promise.resolve();
|
|
|
|
expect(streamResolver.resolveLiveDetail).toHaveBeenCalledTimes(1);
|
|
|
|
pendingDetail.resolve({
|
|
epgMode: 'portal',
|
|
playback: {
|
|
streamUrl: 'https://example.com/xtream.m3u8',
|
|
title: 'Xtream Live',
|
|
},
|
|
epgItems: [buildEpgItem('Xtream Show')],
|
|
});
|
|
await fixture.whenStable();
|
|
fixture.detectChanges();
|
|
|
|
expect(component.activeDetail()).toEqual(
|
|
expect.objectContaining({
|
|
playback: expect.objectContaining({
|
|
streamUrl: 'https://example.com/xtream.m3u8',
|
|
}),
|
|
})
|
|
);
|
|
expect(autoOpenHandledSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
function buildLiveItem(
|
|
sourceType: 'm3u' | 'xtream' | 'stalker'
|
|
): UnifiedCollectionItem {
|
|
if (sourceType === 'm3u') {
|
|
return {
|
|
uid: 'm3u::pl-1::m3u-channel',
|
|
name: 'M3U Live',
|
|
contentType: 'live',
|
|
sourceType: 'm3u',
|
|
playlistId: 'pl-1',
|
|
playlistName: 'Playlist One',
|
|
streamUrl: 'https://example.com/m3u.m3u8',
|
|
channelId: 'm3u-channel',
|
|
tvgId: 'm3u-channel',
|
|
logo: 'm3u.png',
|
|
radio: 'false',
|
|
};
|
|
}
|
|
|
|
if (sourceType === 'xtream') {
|
|
return {
|
|
uid: 'xtream::pl-2::20',
|
|
name: 'Xtream Live',
|
|
contentType: 'live',
|
|
sourceType: 'xtream',
|
|
playlistId: 'pl-2',
|
|
playlistName: 'Playlist Two',
|
|
xtreamId: 20,
|
|
tvgId: '20',
|
|
logo: 'xtream.png',
|
|
};
|
|
}
|
|
|
|
return {
|
|
uid: 'stalker::pl-3::30',
|
|
name: 'Stalker Live',
|
|
contentType: 'live',
|
|
sourceType: 'stalker',
|
|
playlistId: 'pl-3',
|
|
playlistName: 'Playlist Three',
|
|
stalkerId: '30',
|
|
stalkerCmd: 'ffmpeg http://stalker/30',
|
|
tvgId: '30',
|
|
logo: 'stalker.png',
|
|
};
|
|
}
|
|
|
|
function buildProgram(title: string): EpgProgram {
|
|
return {
|
|
start: '2026-03-26T11:00:00.000Z',
|
|
stop: '2026-03-26T12:00:00.000Z',
|
|
channel: 'test-channel',
|
|
title,
|
|
desc: `${title} description`,
|
|
category: null,
|
|
};
|
|
}
|
|
|
|
function buildEpgItem(title: string): EpgItem {
|
|
return {
|
|
id: '1',
|
|
epg_id: '',
|
|
title,
|
|
description: `${title} description`,
|
|
lang: '',
|
|
start: '2026-03-26T11:00:00.000Z',
|
|
end: '2026-03-26T12:00:00.000Z',
|
|
stop: '2026-03-26T12:00:00.000Z',
|
|
channel_id: '1',
|
|
start_timestamp: '1774522800',
|
|
stop_timestamp: '1774526400',
|
|
};
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
|
|
return { promise, resolve, reject };
|
|
}
|