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