fix(epg): name search hits, keep guide coverage strict on mapping failures

- Search results and the unresolved programme dialog show the channel's
  display name (playlist row name, else the XMLTV display name the search
  joined in) instead of the raw XMLTV id.
- The guide coverage read resolves manual mappings through a strict variant
  that rejects on database failure, so a mapped channel can never be reported
  as uncovered and hidden by "Only with EPG".
- Architecture doc describes the tiered active-row resolution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 17:50:55 +02:00
1 parent c15598b33f
commit 9071bbf19b
9 files changed
+77 -20

No files matched your search

@@ -3,6 +3,7 @@ import type EpgEventsType from './epg.events';
const getProgramsForChannels = jest.fn();
const getProgramCoverage = jest.fn();
const resolveChannelIds = jest.fn();
const resolveChannelIdsStrict = jest.fn();
const ipcHandlers = new Map<
string,
(event: unknown, args: unknown) => Promise<unknown>
@@ -25,13 +26,14 @@ jest.mock('./epg-guide-query.service', () => ({
epgGuideQueryService: {
getProgramsForChannels: (...args: unknown[]) =>
getProgramsForChannels(...args),
getProgramCoverage: (...args: unknown[]) =>
getProgramCoverage(...args),
getProgramCoverage: (...args: unknown[]) => getProgramCoverage(...args),
},
}));
jest.mock('./epg-mapping.service', () => ({
resolveChannelIds: (...args: unknown[]) => resolveChannelIds(...args),
resolveChannelIdsStrict: (...args: unknown[]) =>
resolveChannelIdsStrict(...args),
queryByResolvedChannelIds: jest.fn(),
handleGetEpgMapping: jest.fn(),
handleGetEpgMappingsBatch: jest.fn(),
@@ -71,6 +73,7 @@ describe('EPG guide IPC handlers', () => {
(mock) => mock.mockReset()
);
resolveChannelIds.mockResolvedValue(new Map());
resolveChannelIdsStrict.mockResolvedValue(new Map());
({ default: EpgEvents } = await import('./epg.events'));
@@ -97,9 +100,7 @@ describe('EPG guide IPC handlers', () => {
}
it('applies a manual mapping before querying and keys the answer back by the requested key', async () => {
resolveChannelIds.mockResolvedValue(
new Map([['m3u-key', 'xmltv.id']])
);
resolveChannelIds.mockResolvedValue(new Map([['m3u-key', 'xmltv.id']]));
getProgramsForChannels.mockResolvedValue({ 'xmltv.id': [program] });
const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
@@ -161,6 +162,26 @@ describe('EPG guide IPC handlers', () => {
expect(result).toEqual({});
});
it('rejects coverage when the manual-mapping lookup fails, but still answers programmes', async () => {
resolveChannelIdsStrict.mockRejectedValue(new Error('mapping db down'));
getProgramsForChannels.mockResolvedValue({ a: [] });
await expect(
invoke('EPG_GET_PROGRAM_COVERAGE', {
channelIds: ['a'],
fromMs: 0,
toMs: 1,
})
).rejects.toThrow('mapping db down');
await expect(
invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
channelIds: ['a'],
fromMs: 0,
toMs: 1,
})
).resolves.toEqual({ a: [] });
});
it('de-duplicates requested coverage keys and keeps only the covered ones', async () => {
getProgramCoverage.mockResolvedValue(['a']);
@@ -34,14 +34,24 @@ export async function resolveChannelIds(
channelIds: string[]
): Promise<Map<string, string>> {
try {
const db = await getDatabase();
const mappings = await getEpgMappingsBatch(db, channelIds);
return mappings;
return await resolveChannelIdsStrict(channelIds);
} catch {
return new Map();
}
}
/**
* Same lookup, but a database failure rejects. The guide's coverage read
* uses it: answering "unmapped" for a mapped channel would let the
* "Only with EPG" filter hide it, whereas a rejection keeps coverage unknown.
*/
export async function resolveChannelIdsStrict(
channelIds: string[]
): Promise<Map<string, string>> {
const db = await getDatabase();
return getEpgMappingsBatch(db, channelIds);
}
/**
* Run a batch query against mapping-resolved channel IDs and remap the results
* back onto the original request keys the renderer asked for.
@@ -20,6 +20,7 @@ import {
handleSetEpgMapping,
queryByResolvedChannelIds,
resolveChannelIds,
resolveChannelIdsStrict,
} from './epg-mapping.service';
/**
@@ -312,7 +313,9 @@ export default class EpgEvents {
const requested = Array.isArray(args?.channelIds)
? args.channelIds
: [];
const mapping = await resolveChannelIds(requested);
// Strict on purpose: a mapping lookup failure must reject (coverage
// unknown), not report mapped channels as unmapped and uncovered.
const mapping = await resolveChannelIdsStrict(requested);
const resolvedIds = requested.map((id) => mapping.get(id) ?? id);
const covered = new Set(
await epgGuideQueryService.getProgramCoverage({
+8 -3
View File
@@ -1147,9 +1147,14 @@ without that both copies lit up as playing and activating either one played
the first, and suffixing only the repeats was not enough because a real
channel id can itself look like a generated suffix (ids `x`, `x`, `x#1`
produced `x#1` twice). Ids move with the scope and the channel list, so only
ids the guide was just handed may be passed back. The active channel marks the
FIRST row carrying its id (`null` when it is outside the scope), and
`activate(rowId)` resolves the row back to its own channel. Opening the
ids the guide was just handed may be passed back. The active channel is
resolved to a row in three steps, because the store spreads the selected
channel (identity is gone) and copies can share an id: the row whose channel
matches the playing one field for field (every field except the
reducer-rewritten `epgParams`, key order ignored — `isSameChannelEntry`),
else the first same-id row with the same stream url, else the first same-id
row; `null` when the channel is outside the scope. `activate(rowId)`
resolves the row back to its own channel object. Opening the
guide mirrors the sidebar view (`applyInitialScope`): favorites stays
favorites, and the groups view opens on the group the sidebar's rail is
SHOWING — forwarded from `GroupsViewComponent.selectedGroupChange` through the
@@ -339,7 +339,14 @@ describe('M3uEpgGuideSourceService', () => {
searchPrograms.mockResolvedValue([program('a.tv'), program('x')]);
const hits = await service.searchPrograms('news');
expect(hits.map((hit) => hit.channelId)).toEqual(['0:a', null]);
expect(hits.map((hit) => hit.channelName)).toEqual(['Channel a', null]);
expect(hits[1].program.title).toBe('x show');
searchPrograms.mockResolvedValue([
{ ...program('x'), channelName: 'XMLTV Name' },
]);
expect((await service.searchPrograms('x'))[0].channelName).toBe(
'XMLTV Name'
);
expect(searchPrograms).toHaveBeenCalledWith('news', 20);
});
});
@@ -292,18 +292,24 @@ export class M3uEpgGuideSourceService implements EpgGuideSource {
async searchPrograms(query: string): Promise<EpgGuideSearchHit[]> {
const programs =
(await this.epgBridge.searchPrograms(query, SEARCH_LIMIT)) ?? [];
const byKey = new Map<string, string>();
const byKey = new Map<string, EpgGuideChannel>();
for (const channel of this.channels()) {
// Duplicate rows share an EPG key; jump to the first, like
// `activeChannelId`.
if (channel.epgKey !== null && !byKey.has(channel.epgKey)) {
byKey.set(channel.epgKey, channel.id);
byKey.set(channel.epgKey, channel);
}
}
return programs.map((program) => ({
channelId: byKey.get(program.channel) ?? null,
program,
}));
return programs.map((program) => {
const row = byKey.get(program.channel);
return {
channelId: row?.id ?? null,
// The playlist's own name when the row is known, else the
// XMLTV display name the search joined in; never the raw id.
channelName: row?.name ?? program.channelName ?? null,
program,
};
});
}
private keyedChannels(
@@ -88,7 +88,10 @@ export class EpgGuideDialogController {
channelName: channel.name,
channelLogo: channel.logoUrl,
}
: { ...hit.program }
: {
...hit.program,
channelName: hit.channelName ?? null,
}
)
.subscribe();
}
@@ -48,6 +48,8 @@ export interface EpgGuideCatchUp {
*/
export interface EpgGuideSearchHit {
channelId: string | null;
/** Display name for the hit's channel: the playlist row's when resolved, else the guide source's own (e.g. the XMLTV display name). */
channelName?: string | null;
program: EpgProgram;
}
@@ -47,7 +47,7 @@
searchHitStartMs(hit)
| date: 'EEE d MMM HH:mm' : '' : currentLocale()
}}
· {{ hit.program.channel }}
· {{ hit.channelName ?? hit.program.channel }}
</span>
</button>
} @empty {