mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row
EpgQueryService.getChannelMetadata swallowed database errors into {}, so the
guide's coverage read could publish an empty set after a transient failure;
the guide now uses the strict resolveChannelMetadata (getChannelMetadata is
the fail-soft wrapper around it). The active guide row is matched on the
whole channel entry (all fields except the reducer-rewritten epgParams)
before widening to url and id, so copies that differ only in playback
headers or logo are told apart.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
a49e56f5de
commit
c00ebe69a2
7 files changed
+189
-65
No files matched your search
@@ -422,15 +422,18 @@ describeWithSqlite('guideWindowCondition rendered against SQLite', () => {
|
||||
});
|
||||
|
||||
describe('EpgGuideQueryService', () => {
|
||||
const getChannelMetadata = jest.fn();
|
||||
const resolveChannelMetadata = jest.fn();
|
||||
let service: EpgGuideQueryService;
|
||||
|
||||
beforeEach(() => {
|
||||
getDatabase.mockReset();
|
||||
getChannelMetadata.mockReset();
|
||||
resolveChannelMetadata.mockReset();
|
||||
(epgLogger.log as jest.Mock).mockReset();
|
||||
(epgLogger.error as jest.Mock).mockReset();
|
||||
service = new EpgGuideQueryService({ getChannelMetadata }, '[Test]');
|
||||
service = new EpgGuideQueryService(
|
||||
{ resolveChannelMetadata },
|
||||
'[Test]'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty object (no per-key placeholders) for an invalid window', async () => {
|
||||
@@ -440,11 +443,30 @@ describe('EpgGuideQueryService', () => {
|
||||
toMs: FROM,
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
expect(getChannelMetadata).not.toHaveBeenCalled();
|
||||
expect(resolveChannelMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects coverage when the channel lookup itself fails', async () => {
|
||||
resolveChannelMetadata.mockRejectedValue(new Error('lookup down'));
|
||||
await expect(
|
||||
service.getProgramCoverage({
|
||||
channelIds: ['a'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
})
|
||||
).rejects.toThrow('lookup down');
|
||||
// Programmes keep the documented fail-soft answer.
|
||||
await expect(
|
||||
service.getProgramsForChannels({
|
||||
channelIds: ['a'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
})
|
||||
).resolves.toEqual({ a: [] });
|
||||
});
|
||||
|
||||
it('rethrows a coverage failure after logging it', async () => {
|
||||
getChannelMetadata.mockResolvedValue({
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
a: { id: 'a', displayName: 'A', iconUrl: null },
|
||||
});
|
||||
getDatabase.mockRejectedValue(new Error('locked'));
|
||||
@@ -458,7 +480,7 @@ describe('EpgGuideQueryService', () => {
|
||||
});
|
||||
|
||||
it('resolves keys through channel metadata and maps rows back onto every requested key', async () => {
|
||||
getChannelMetadata.mockResolvedValue({
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null },
|
||||
'zdf.de': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null },
|
||||
unknown: null,
|
||||
@@ -490,7 +512,7 @@ describe('EpgGuideQueryService', () => {
|
||||
toMs: TO,
|
||||
});
|
||||
|
||||
expect(getChannelMetadata).toHaveBeenCalledWith(
|
||||
expect(resolveChannelMetadata).toHaveBeenCalledWith(
|
||||
['ZDF HD', 'zdf.de', 'unknown'],
|
||||
{}
|
||||
);
|
||||
@@ -511,7 +533,7 @@ describe('EpgGuideQueryService', () => {
|
||||
});
|
||||
|
||||
it('scopes the programme rows to the requested source URLs plus legacy (unsourced) rows', async () => {
|
||||
getChannelMetadata.mockResolvedValue({
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
a: { id: 'a', displayName: 'A', iconUrl: null },
|
||||
});
|
||||
const whereCalls: unknown[] = [];
|
||||
@@ -526,7 +548,7 @@ describe('EpgGuideQueryService', () => {
|
||||
sourceUrls: ['https://guide.example.com/epg.xml'],
|
||||
});
|
||||
|
||||
expect(getChannelMetadata).toHaveBeenCalledWith(['a'], {
|
||||
expect(resolveChannelMetadata).toHaveBeenCalledWith(['a'], {
|
||||
sourceUrls: ['https://guide.example.com/epg.xml'],
|
||||
});
|
||||
const condition = flattenSql(whereCalls[0]).toLowerCase();
|
||||
@@ -535,7 +557,7 @@ describe('EpgGuideQueryService', () => {
|
||||
});
|
||||
|
||||
it('fails soft when the database throws', async () => {
|
||||
getChannelMetadata.mockResolvedValue({
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
a: { id: 'a', displayName: 'A', iconUrl: null },
|
||||
});
|
||||
getDatabase.mockRejectedValue(new Error('locked'));
|
||||
@@ -550,7 +572,7 @@ describe('EpgGuideQueryService', () => {
|
||||
});
|
||||
|
||||
it('keeps a provider key named __proto__ as an own response property', async () => {
|
||||
getChannelMetadata.mockResolvedValue({
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
__proto__: { id: 'proto.tv', displayName: 'Proto', iconUrl: null },
|
||||
});
|
||||
getDatabase.mockResolvedValue({
|
||||
@@ -581,7 +603,7 @@ describe('EpgGuideQueryService', () => {
|
||||
|
||||
it('drops channel keys cut by the per-request cap from the result entirely', async () => {
|
||||
const ids = Array.from({ length: 101 }, (_, index) => `ch-${index}`);
|
||||
getChannelMetadata.mockResolvedValue({});
|
||||
resolveChannelMetadata.mockResolvedValue({});
|
||||
|
||||
const result = await service.getProgramsForChannels({
|
||||
channelIds: ids,
|
||||
@@ -602,7 +624,7 @@ describe('EpgGuideQueryService', () => {
|
||||
});
|
||||
|
||||
it('reports coverage for the requested keys whose channel has a programme in the window', async () => {
|
||||
getChannelMetadata.mockResolvedValue({
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null },
|
||||
ARTE: { id: 'arte.de', displayName: 'ARTE', iconUrl: null },
|
||||
none: null,
|
||||
@@ -627,7 +649,7 @@ describe('EpgGuideQueryService', () => {
|
||||
|
||||
it('does not drop any of 150 requested keys under the larger coverage cap', async () => {
|
||||
const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`);
|
||||
getChannelMetadata.mockResolvedValue({});
|
||||
resolveChannelMetadata.mockResolvedValue({});
|
||||
|
||||
await service.getProgramCoverage({
|
||||
channelIds: ids,
|
||||
@@ -635,7 +657,7 @@ describe('EpgGuideQueryService', () => {
|
||||
toMs: TO,
|
||||
});
|
||||
|
||||
expect(getChannelMetadata).toHaveBeenCalledWith(ids, {});
|
||||
expect(resolveChannelMetadata).toHaveBeenCalledWith(ids, {});
|
||||
});
|
||||
|
||||
it('logs the coverage-specific truncation message when the coverage cap drops keys', async () => {
|
||||
@@ -643,7 +665,7 @@ describe('EpgGuideQueryService', () => {
|
||||
{ length: EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST + 1 },
|
||||
(_, index) => `ch-${index}`
|
||||
);
|
||||
getChannelMetadata.mockResolvedValue({});
|
||||
resolveChannelMetadata.mockResolvedValue({});
|
||||
|
||||
await service.getProgramCoverage({
|
||||
channelIds: ids,
|
||||
|
||||
@@ -38,7 +38,8 @@ export type {
|
||||
NormalizedGuideWindow,
|
||||
} from './epg-guide-window.util';
|
||||
|
||||
type ChannelResolver = Pick<EpgQueryService, 'getChannelMetadata'>;
|
||||
/** The strict resolver: a lookup failure rejects instead of answering `{}`. */
|
||||
type ChannelResolver = Pick<EpgQueryService, 'resolveChannelMetadata'>;
|
||||
|
||||
/**
|
||||
* Overlap test in SQLite `datetime()` so provider-local offsets in the
|
||||
@@ -252,7 +253,7 @@ export class EpgGuideQueryService {
|
||||
private async resolveChannelIds(
|
||||
window: NormalizedGuideWindow
|
||||
): Promise<Map<string, string>> {
|
||||
const metadata = await this.resolver.getChannelMetadata(
|
||||
const metadata = await this.resolver.resolveChannelMetadata(
|
||||
window.channelIds,
|
||||
window.sourceUrls.length > 0
|
||||
? { sourceUrls: window.sourceUrls }
|
||||
|
||||
@@ -344,52 +344,62 @@ export class EpgQueryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve channel lookup keys to XMLTV channel metadata. Throws on a
|
||||
* database failure so callers that must tell "no match" apart from "could
|
||||
* not look up" (the guide's coverage read) can fail open.
|
||||
*/
|
||||
async resolveChannelMetadata(
|
||||
channelIds: string[],
|
||||
options: { sourceUrls?: string[] } = {}
|
||||
): Promise<Record<string, EpgChannelMetadata | null>> {
|
||||
const normalizedChannelIds =
|
||||
this.normalizeChannelLookupKeys(channelIds);
|
||||
|
||||
if (normalizedChannelIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
|
||||
let candidates = await this.selectChannelMetadataCandidates(
|
||||
db,
|
||||
normalizedChannelIds,
|
||||
sourceUrls
|
||||
);
|
||||
if (sourceUrls.length > 0) {
|
||||
const missingChannelIds = normalizedChannelIds.filter(
|
||||
(channelId) =>
|
||||
!this.resolveChannelMetadataCandidate(channelId, candidates)
|
||||
);
|
||||
if (missingChannelIds.length > 0) {
|
||||
candidates = [
|
||||
...candidates,
|
||||
...(await this.selectChannelMetadataCandidates(
|
||||
db,
|
||||
missingChannelIds,
|
||||
sourceUrls,
|
||||
{ legacyOnly: true }
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
normalizedChannelIds.map((channelId) => [
|
||||
channelId,
|
||||
this.resolveChannelMetadataCandidate(channelId, candidates),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** Fail-soft variant of `resolveChannelMetadata`: a failure logs and answers `{}`. */
|
||||
async getChannelMetadata(
|
||||
channelIds: string[],
|
||||
options: { sourceUrls?: string[] } = {}
|
||||
): Promise<Record<string, EpgChannelMetadata | null>> {
|
||||
try {
|
||||
const normalizedChannelIds =
|
||||
this.normalizeChannelLookupKeys(channelIds);
|
||||
|
||||
if (normalizedChannelIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
|
||||
let candidates = await this.selectChannelMetadataCandidates(
|
||||
db,
|
||||
normalizedChannelIds,
|
||||
sourceUrls
|
||||
);
|
||||
if (sourceUrls.length > 0) {
|
||||
const missingChannelIds = normalizedChannelIds.filter(
|
||||
(channelId) =>
|
||||
!this.resolveChannelMetadataCandidate(
|
||||
channelId,
|
||||
candidates
|
||||
)
|
||||
);
|
||||
if (missingChannelIds.length > 0) {
|
||||
candidates = [
|
||||
...candidates,
|
||||
...(await this.selectChannelMetadataCandidates(
|
||||
db,
|
||||
missingChannelIds,
|
||||
sourceUrls,
|
||||
{ legacyOnly: true }
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
normalizedChannelIds.map((channelId) => [
|
||||
channelId,
|
||||
this.resolveChannelMetadataCandidate(channelId, candidates),
|
||||
])
|
||||
);
|
||||
return await this.resolveChannelMetadata(channelIds, options);
|
||||
} catch (error) {
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
|
||||
Reference in new issue
Block a user