From 7a86485b4cd2f0e158c8c8e7cda9be269739fefe Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 19 Jul 2026 19:45:59 +0200 Subject: [PATCH] fix(epg): deduplicate program rows in SQL before applying row limits Codex follow-up: the JS-level slot dedup ran after the SQL LIMIT, so duplicate cross-source rows consumed the cap and truncated real data. Moved the dedup into SQL with GROUP BY, applied before the limits: - selectChannelPrograms / selectLegacyChannelPrograms: GROUP BY (channel_id, start, title) before ORDER BY start LIMIT 500, so the timeline cap counts distinct programmes rather than duplicate rows - selectCurrentProgramsForChannelIds: GROUP BY channel_id before LIMIT channelIds.length, so duplicate cross-source current slots can't starve other channels of their current-programme preview The JS toEpgPrograms() dedup stays as a safety net (e.g. legacy NULL-source rows the unique index treats as distinct). Test query-chain mocks updated for the new groupBy link. Co-Authored-By: Claude Fable 5 --- .../src/app/events/epg-query.service.spec.ts | 56 ++++++++++--------- .../src/app/events/epg-query.service.ts | 15 +++++ .../src/app/events/epg.events.spec.ts | 5 +- 3 files changed, 49 insertions(+), 27 deletions(-) diff --git a/apps/electron-backend/src/app/events/epg-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-query.service.spec.ts index 5139701dc..577096476 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.spec.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.spec.ts @@ -66,16 +66,35 @@ function createLimitedSelectChain( limitResult: unknown, whereCalls: unknown[] ): { from: jest.Mock } { - const limit = jest.fn().mockResolvedValue(limitResult); + const chain: Record = { + limit: jest.fn().mockResolvedValue(limitResult), + }; + // groupBy/orderBy are optional links in the various query shapes; each + // returns the chain so where().groupBy().orderBy().limit() (and any + // subset) resolves to the same data. + chain.groupBy = jest.fn(() => chain); + chain.orderBy = jest.fn(() => chain); const where = jest.fn((condition: unknown) => { whereCalls.push(condition); - return { limit }; + return chain; }); return { from: jest.fn(() => ({ where })), }; } +/** Program-query chain: from().where().groupBy().orderBy().limit(). */ +function createProgramChain(rows: unknown): { from: jest.Mock } { + const chain: Record = { + limit: jest.fn().mockResolvedValue(rows), + }; + chain.groupBy = jest.fn(() => chain); + chain.orderBy = jest.fn(() => chain); + return { + from: jest.fn(() => ({ where: jest.fn(() => chain) })), + }; +} + describe('EpgQueryService', () => { let service: EpgQueryService; @@ -434,15 +453,6 @@ describe('EpgQueryService', () => { episodeNum: null, sourceUrl: null, }; - const programChain = { - from: jest.fn(() => ({ - where: jest.fn(() => ({ - orderBy: jest.fn(() => ({ - limit: jest.fn().mockResolvedValue([programRow]), - })), - })), - })), - }; const select = jest .fn() @@ -452,7 +462,7 @@ describe('EpgQueryService', () => { // categories / mappings, no arbitrary candidate cap. .mockReturnValueOnce({ from: jest.fn(() => joinChain) }) // selectChannelPrograms for the mapped id. - .mockReturnValueOnce(programChain); + .mockReturnValueOnce(createProgramChain([programRow])); getDatabase.mockResolvedValue({ select }); @@ -478,19 +488,6 @@ describe('EpgQueryService', () => { rating: null, episodeNum: null, }; - const programChain = { - from: jest.fn(() => ({ - where: jest.fn(() => ({ - orderBy: jest.fn(() => ({ - limit: jest.fn().mockResolvedValue([ - { ...dupRow, sourceUrl: 'https://a.example/g.xml' }, - { ...dupRow, id: 2, sourceUrl: 'https://b.example/g.xml' }, - ]), - })), - })), - })), - }; - const select = jest .fn() // getMapping direct + Xtream fallback — both miss. @@ -508,7 +505,14 @@ describe('EpgQueryService', () => { })), }) // selectChannelPrograms — two sources, same channel/start/title. - .mockReturnValueOnce(programChain); + // The SQL GROUP BY would collapse these; the mock returns both to + // prove the JS safety-net dedup also holds. + .mockReturnValueOnce( + createProgramChain([ + { ...dupRow, sourceUrl: 'https://a.example/g.xml' }, + { ...dupRow, id: 2, sourceUrl: 'https://b.example/g.xml' }, + ]) + ); getDatabase.mockResolvedValue({ select }); diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts index ca68945e5..8ed16023e 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -546,6 +546,13 @@ export class EpgQueryService { sourceUrls ) ) + // Collapse identical slots from multiple sources in SQL so the + // 500-row cap counts distinct programmes, not duplicate rows. + .groupBy( + schema.epgPrograms.channelId, + schema.epgPrograms.start, + schema.epgPrograms.title + ) .orderBy(schema.epgPrograms.start) .limit(500); } @@ -569,6 +576,11 @@ export class EpgQueryService { { legacyOnly: true } ) ) + .groupBy( + schema.epgPrograms.channelId, + schema.epgPrograms.start, + schema.epgPrograms.title + ) .orderBy(schema.epgPrograms.start) .limit(500); } @@ -600,6 +612,9 @@ export class EpgQueryService { options ) ) + // One current row per channel so duplicate cross-source slots + // don't consume the per-channel cap and starve other channels. + .groupBy(schema.epgPrograms.channelId) .limit(channelIds.length); } diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index 6f6799a9a..1f2977a66 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -384,6 +384,7 @@ describe('EpgEvents', () => { const chain: Record = {} as Record; chain.where = jest.fn().mockReturnValue(chain); chain.innerJoin = jest.fn().mockReturnValue(chain); + chain.groupBy = jest.fn().mockReturnValue(chain); chain.orderBy = jest.fn().mockReturnValue(chain); chain.limit = jest.fn().mockResolvedValue(data); return chain; @@ -470,12 +471,14 @@ describe('EpgEvents', () => { const select = jest.fn(); const from = jest.fn(); const where = jest.fn(); + const groupBy = jest.fn(); const orderBy = jest.fn(); const limit = jest.fn(); select.mockImplementation(() => ({ from })); from.mockReturnValue({ where }); - where.mockReturnValue({ orderBy }); + where.mockReturnValue({ groupBy }); + groupBy.mockReturnValue({ orderBy }); orderBy.mockReturnValue({ limit }); limit.mockResolvedValue([ {