mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: enhance EPG handling and improve component tests for live streams
This commit is contained in:
1 parent
2e92629154
commit
d22515657d
4 files changed
+204
-45
No files matched your search
+16
-8
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user