mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
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({
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user