feat: enhance EPG handling and improve component tests for live streams

This commit is contained in:
4gray committed 2026-04-05 19:04:50 +02:00
1 parent 2e92629154
commit d22515657d
4 files changed
+204 -45

No files matched your search

@@ -184,9 +184,15 @@ export class UnifiedLiveTabComponent {
return;
}
if (this.activeUid() === matchedItem.uid && this.activeDetail()) {
this.autoOpenHandled.emit();
return;
if (this.activeUid() === matchedItem.uid) {
if (this.activeDetail()) {
this.autoOpenHandled.emit();
return;
}
if (this.isSelecting()) {
return;
}
}
void this.activateItem(matchedItem, true);
@@ -200,7 +206,9 @@ export class UnifiedLiveTabComponent {
}
async onChannelSelected(channel: UnifiedFavoriteChannel): Promise<void> {
const item = this.items().find((candidate) => candidate.uid === channel.uid);
const item = this.items().find(
(candidate) => candidate.uid === channel.uid
);
if (!item) {
return;
}
@@ -208,7 +216,9 @@ export class UnifiedLiveTabComponent {
}
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
const item = this.items().find((candidate) => candidate.uid === channel.uid);
const item = this.items().find(
(candidate) => candidate.uid === channel.uid
);
if (item) {
this.removeItem.emit(item);
}
@@ -230,9 +240,7 @@ export class UnifiedLiveTabComponent {
this.activeUid.set(null);
}
private async loadEpgMap(
items: UnifiedCollectionItem[]
): Promise<void> {
private async loadEpgMap(items: UnifiedCollectionItem[]): Promise<void> {
const epgMap = await this.streamResolver.loadEpgForItems(items);
this.epgMap.set(epgMap);
}
@@ -1,6 +1,9 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { XtreamApiService, XtreamUrlService } from '@iptvnator/portal/xtream/data-access';
import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { DataService, PlaylistsService } from 'services';
import { Playlist } from 'shared-interfaces';
@@ -304,6 +307,70 @@ describe('StreamResolverService', () => {
expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1);
});
it('falls back to empty Xtream EPG when the provider response is too slow', async () => {
jest.useFakeTimers();
try {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>)
);
xtreamUrl.constructLiveUrl.mockReturnValue(
'https://xtream.example.com/live/1'
);
xtreamApi.getShortEpg.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve([
{
id: '1',
epg_id: '',
title: 'Late Xtream Show',
description: 'Delayed Xtream program',
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',
},
]),
10_000
);
})
);
const detailPromise = service.resolveLiveDetail({
uid: 'xtream::xtream-1::1',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 1,
} satisfies UnifiedCollectionItem);
await jest.advanceTimersByTimeAsync(3000);
await expect(detailPromise).resolves.toMatchObject({
epgMode: 'portal',
playback: expect.objectContaining({
streamUrl: 'https://xtream.example.com/live/1',
}),
epgItems: [],
});
} finally {
jest.useRealTimers();
}
});
it('loads current Stalker EPG previews for live collection rows', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -322,12 +389,8 @@ describe('StreamResolverService', () => {
time: '2026-03-26T11:00:00.000Z',
time_to: '2026-03-26T12:00:00.000Z',
ch_id: '77',
start_timestamp: String(
Math.floor(Date.now() / 1000) - 60
),
stop_timestamp: String(
Math.floor(Date.now() / 1000) + 60
),
start_timestamp: String(Math.floor(Date.now() / 1000) - 60),
stop_timestamp: String(Math.floor(Date.now() / 1000) + 60),
},
],
});
@@ -61,11 +61,29 @@ export class StreamResolverService {
private readonly dataService = inject(DataService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly m3uEpgTimeoutMs = 3000;
private readonly portalEpgTimeoutMs = 3000;
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
private readonly xtreamEpgFailureTimestamps = new Map<string, number>();
private readonly xtreamEpgCacheTtlMs = 60 * 1000;
private readonly xtreamEpgFailureCooldownMs = 60 * 1000;
private async getElectronPlaylist(
playlistId: string
): Promise<Playlist | undefined> {
if (typeof window === 'undefined') {
return undefined;
}
try {
return (
(await window.electron?.dbGetAppPlaylist?.(playlistId)) ??
undefined
);
} catch {
return undefined;
}
}
async resolvePlayback(
item: UnifiedCollectionItem
): Promise<ResolvedPortalPlayback> {
@@ -159,12 +177,7 @@ export class StreamResolverService {
for (const [playlistId, playlistItems] of stalkerByPlaylist.entries()) {
tasks.push(
this.loadStalkerEpgBatch(
playlistId,
playlistItems,
epgMap,
now
)
this.loadStalkerEpgBatch(playlistId, playlistItems, epgMap, now)
);
}
@@ -182,7 +195,11 @@ export class StreamResolverService {
item: UnifiedCollectionItem
): Promise<ResolvedLiveCollectionDetail> {
const playback = await this.resolveXtream(item);
const epgItems = await this.loadXtreamEpgItems(item);
const epgItems = await this.withFallbackTimeout(
this.loadXtreamEpgItems(item),
this.portalEpgTimeoutMs,
[]
);
return {
playback,
@@ -195,7 +212,11 @@ export class StreamResolverService {
item: UnifiedCollectionItem
): Promise<ResolvedLiveCollectionDetail> {
const playback = await this.resolveStalker(item);
const epgItems = await this.loadStalkerEpgItems(item, 10);
const epgItems = await this.withFallbackTimeout(
this.loadStalkerEpgItems(item, 10),
this.portalEpgTimeoutMs,
[]
);
return {
playback,
@@ -325,9 +346,10 @@ export class StreamResolverService {
)) as Playlist | undefined;
const channelId = String(
item.stalkerId ??
((item.stalkerItem as Record<string, unknown> | undefined)?.[
(item.stalkerItem as Record<string, unknown> | undefined)?.[
'id'
] ?? '')
] ??
''
).trim();
if (!playlist || !channelId) {
@@ -338,9 +360,11 @@ export class StreamResolverService {
}
private async getXtreamCredentials(playlistId: string) {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
)) as Playlist | undefined;
const playlist =
(await this.getElectronPlaylist(playlistId)) ??
((await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
)) as Playlist | undefined);
if (!playlist?.serverUrl || !playlist.username || !playlist.password) {
return null;
@@ -378,7 +402,9 @@ export class StreamResolverService {
this.findM3uChannel(playlist?.playlist?.items ?? [], item) ??
this.buildFallbackM3uChannel(item);
const epgPrograms = includePrograms
? await this.fetchM3uPrograms(this.getM3uEpgLookupKey(channel, item))
? await this.fetchM3uPrograms(
this.getM3uEpgLookupKey(channel, item)
)
: [];
return {
@@ -467,7 +493,8 @@ export class StreamResolverService {
Number(item.start_timestamp) <= nowSeconds &&
nowSeconds < Number(item.stop_timestamp)
) ?? null;
const epgKey = channel.tvgId?.trim() || channel.name?.trim();
const epgKey =
channel.tvgId?.trim() || channel.name?.trim();
if (!epgKey) {
return;
@@ -491,7 +518,8 @@ export class StreamResolverService {
: null
);
} catch {
const epgKey = channel.tvgId?.trim() || channel.name?.trim();
const epgKey =
channel.tvgId?.trim() || channel.name?.trim();
if (epgKey) {
epgMap.set(epgKey, null);
}
@@ -546,11 +574,7 @@ export class StreamResolverService {
streamId: number,
limit: number
): Promise<EpgItem[]> {
const cacheKey = this.getXtreamEpgCacheKey(
playlistId,
streamId,
limit
);
const cacheKey = this.getXtreamEpgCacheKey(playlistId, streamId, limit);
const cached = this.getCachedXtreamEpgItems(cacheKey);
if (cached !== null) {
return cached;
@@ -599,7 +623,8 @@ export class StreamResolverService {
await Promise.all(
channels.map(async (channel) => {
const channelId = String(channel.stalkerId ?? '').trim();
const epgKey = channel.tvgId?.trim() || channelId || channel.name?.trim();
const epgKey =
channel.tvgId?.trim() || channelId || channel.name?.trim();
if (!channelId || !epgKey) {
return;
@@ -652,7 +677,7 @@ export class StreamResolverService {
const epgData = Array.isArray(response?.js)
? response.js
: response?.js?.data ?? [];
: (response?.js?.data ?? []);
return epgData.map((item) => ({
id: String(item.id ?? ''),
@@ -11,7 +11,11 @@ import {
GlobalFavoritesListComponent,
UnifiedLiveTabComponent,
} from '@iptvnator/portal/shared/ui';
import { ArtPlayerComponent, HtmlVideoPlayerComponent, VjsPlayerComponent } from '@iptvnator/ui/playback';
import {
ArtPlayerComponent,
HtmlVideoPlayerComponent,
VjsPlayerComponent,
} from '@iptvnator/ui/playback';
import { EpgListComponent } from '@iptvnator/ui/epg';
import { ResizableDirective } from 'components';
import { SettingsStore } from 'services';
@@ -233,9 +237,7 @@ describe('UnifiedLiveTabComponent', () => {
expect(
fixture.nativeElement.querySelector('app-epg-list')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-view')
).toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
});
it('does not wait for M3U program lookup before opening playback', async () => {
@@ -321,9 +323,7 @@ describe('UnifiedLiveTabComponent', () => {
expect(
fixture.nativeElement.querySelector('app-epg-view')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-list')
).toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull();
});
it('renders shared EPG view for Stalker items and records recent history', async () => {
@@ -354,6 +354,69 @@ describe('UnifiedLiveTabComponent', () => {
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(