diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index ddfd3136c..25219e131 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -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 { - 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 { + private async loadEpgMap(items: UnifiedCollectionItem[]): Promise { const epgMap = await this.streamResolver.loadEpgForItems(items); this.epgMap.set(epgMap); } diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts index dde8c52e9..c0d536ea9 100644 --- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts @@ -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) + ); + 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), }, ], }); diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts index d0b3d37a3..b5843ae43 100644 --- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts @@ -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(); private readonly xtreamEpgFailureTimestamps = new Map(); private readonly xtreamEpgCacheTtlMs = 60 * 1000; private readonly xtreamEpgFailureCooldownMs = 60 * 1000; + private async getElectronPlaylist( + playlistId: string + ): Promise { + if (typeof window === 'undefined') { + return undefined; + } + + try { + return ( + (await window.electron?.dbGetAppPlaylist?.(playlistId)) ?? + undefined + ); + } catch { + return undefined; + } + } + async resolvePlayback( item: UnifiedCollectionItem ): Promise { @@ -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 { 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 { 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 | undefined)?.[ + (item.stalkerItem as Record | 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 { - 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 ?? ''), diff --git a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts index 9618c4473..57c59b5d9 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts @@ -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(