diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts index da1a30d8c..c19083b3d 100644 --- a/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts @@ -443,6 +443,20 @@ describe('EpgGuideQueryService', () => { expect(getChannelMetadata).not.toHaveBeenCalled(); }); + it('rethrows a coverage failure after logging it', async () => { + getChannelMetadata.mockResolvedValue({ + a: { id: 'a', displayName: 'A', iconUrl: null }, + }); + getDatabase.mockRejectedValue(new Error('locked')); + await expect( + service.getProgramCoverage({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + }) + ).rejects.toThrow('locked'); + }); + it('resolves keys through channel metadata and maps rows back onto every requested key', async () => { getChannelMetadata.mockResolvedValue({ 'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null }, diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.ts index a06dd02dc..9e21f7617 100644 --- a/apps/electron-backend/src/app/events/epg-guide-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.ts @@ -207,7 +207,10 @@ export class EpgGuideQueryService { 'Error loading guide coverage:', error ); - return []; + // Unlike programmes, coverage must not fail soft: an empty answer + // reads as "no channel has EPG" and the "Only with EPG" toggle + // would hide every row. A rejection keeps coverage unknown. + throw error; } } diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index f2e2c17f7..a858fe907 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -305,6 +305,7 @@ export default class EpgEvents { return answer; } + /** A thrown error rejects the renderer's `invoke`, which is the intended fail-open path for coverage. */ private static async handleGetGuideCoverage( args: ElectronBridgeEpgGuideWindow ): Promise { diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts index beb0450de..5a1d1e5c2 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts @@ -164,6 +164,33 @@ describe('M3uEpgGuideSourceService', () => { expect((await service.loadCoverage(window)).size).toBe(0); }); + it('marks the duplicate whose stream url matches the playing channel', () => { + const first = makeChannel('dup'); + const second = { + ...makeChannel('dup'), + url: 'https://example.com/second.m3u8', + }; + channels.set([first, second]); + activeChannel.set(second); + expect(service.activeChannelId()).toBe('1:dup'); + activeChannel.set({ + ...makeChannel('dup'), + url: 'https://example.com/unknown.m3u8', + }); + expect(service.activeChannelId()).toBe('0:dup'); + }); + + it('propagates a coverage failure so the guide keeps coverage unknown', async () => { + getProgramCoverage.mockRejectedValue(new Error('bridge down')); + await expect( + service.loadCoverage({ + channels: service.channels(), + fromMs: 1, + toMs: 2, + }) + ).rejects.toThrow('bridge down'); + }); + it('mirrors the active channel and dispatches playback on activate', () => { expect(service.activeChannelId()).toBe('0:a'); service.activate('1:b'); diff --git a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts index cfa52569f..3e7ae6398 100644 --- a/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts @@ -166,8 +166,15 @@ export class M3uEpgGuideSourceService implements EpgGuideSource { if (!active) { return null; } + // Entries can share an id but not a stream: prefer the row whose + // url matches too, so selecting the second copy shows it active. + const rows = this.scopedRows(); + const exact = rows.find( + (row) => + row.channel.id === active.id && row.channel.url === active.url + ); return ( - this.scopedRows().find((row) => row.channel.id === active.id) + (exact ?? rows.find((row) => row.channel.id === active.id)) ?.rowId ?? null ); }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts index fb694d85e..5e36715ba 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts @@ -217,6 +217,16 @@ describe('EpgGuideComponent', () => { expect(activate).toHaveBeenCalledWith('c'); }); + it('drops the programme search when the scope changes', async () => { + await settle(fixture); + component.onSearchQueryChange('news'); + expect(component.searchQuery()).toBe('news'); + component.setScope('all'); + expect(component.searchQuery()).toBe(''); + expect(component.searchResults()).toEqual([]); + expect(setScope).toHaveBeenCalledWith('all'); + }); + it('closes on Escape and steps days with PageUp/PageDown', async () => { await settle(fixture); const close = jest.fn(); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts index ae10e55de..5c7809590 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts @@ -295,6 +295,8 @@ export class EpgGuideComponent implements OnDestroy { setScope(scopeId: string): void { this.source.setScope(scopeId); this.focus.set(null); + // Search hits carry scope-local row ids, so none may outlive the scope. + this.search.setQuery(''); } setOnlyWithEpg(value: boolean): void {