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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-19 19:45:59 +02:00
1 parent 2521092753
commit 7a86485b4c
3 files changed
+49 -27

No files matched your search

@@ -66,16 +66,35 @@ function createLimitedSelectChain(
limitResult: unknown,
whereCalls: unknown[]
): { from: jest.Mock } {
const limit = jest.fn().mockResolvedValue(limitResult);
const chain: Record<string, jest.Mock> = {
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<string, jest.Mock> = {
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 });
@@ -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);
}
@@ -384,6 +384,7 @@ describe('EpgEvents', () => {
const chain: Record<string, jest.Mock> = {} as Record<string, jest.Mock>;
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([
{