fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown

Search hits carry scope-local row ids, so a scope change drops them.
Two playlist entries can share an id but not a stream, so the active row
is matched by id + url before falling back to the id. A failed coverage
query now rejects instead of answering an empty set, which the guide
already treats as "coverage unknown" (every row stays visible).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 16:46:43 +02:00
1 parent 2ab44ebb19
commit 378e57add7
7 files changed
+66 -2

No files matched your search

@@ -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 },
@@ -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;
}
}
@@ -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<string[]> {
@@ -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');
@@ -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
);
});
@@ -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();
@@ -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 {