mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
fix: include programs that start or end within requested guide date range
This commit is contained in:
@@ -3,6 +3,7 @@ import { v4 } from 'uuid';
|
||||
import type { ChannelOrm } from '../db/schema/Channel.ts';
|
||||
import type { Lineup } from '../db/derived_types/Lineup.ts';
|
||||
import type { MaterializedChannelPrograms } from './XmlTvWriter.ts';
|
||||
import type { ChannelPrograms } from './TvGuideService.ts';
|
||||
import dayjsBase from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
||||
import dayjs from '../util/dayjs.ts';
|
||||
@@ -136,6 +137,252 @@ describe('TVGuideService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChannelLineup', () => {
|
||||
// Helper: build an offline GuideItem at a given start with a given duration
|
||||
function makeGuideItem(startTimeMs: number, durationMs: number) {
|
||||
return {
|
||||
lineupItem: { type: 'offline' as const, durationMs },
|
||||
startTimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
function makeService() {
|
||||
return new TVGuideService(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
makeMockLogger() as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ write: vi.fn().mockResolvedValue(undefined) } as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ push: vi.fn() } as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ loadAllLineups: vi.fn().mockResolvedValue({}) } as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ getProgramsByIds: vi.fn().mockResolvedValue([]) } as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{} as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{} as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{} as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{} as any,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the service's private cache directly so we can test
|
||||
* getChannelLineup without going through guide generation.
|
||||
*/
|
||||
function seedCache(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
service: any,
|
||||
channel: ChannelOrm,
|
||||
programs: ReturnType<typeof makeGuideItem>[],
|
||||
cacheEndMs: number,
|
||||
) {
|
||||
const entry: ChannelPrograms = {
|
||||
channel,
|
||||
programs: programs as ChannelPrograms['programs'],
|
||||
};
|
||||
service['cachedGuide'] = { [channel.uuid]: entry };
|
||||
service['lastEndTime'] = { [channel.uuid]: cacheEndMs };
|
||||
}
|
||||
|
||||
// Base timestamp: midnight UTC 2024-01-01
|
||||
const BASE = new Date('2024-01-01T00:00:00Z').getTime();
|
||||
const MIN = 60_000;
|
||||
const HOUR = 60 * MIN;
|
||||
|
||||
// The query range under test: [BASE + 60min, BASE + 120min)
|
||||
const rangeStart = new Date(BASE + 60 * MIN);
|
||||
const rangeEnd = new Date(BASE + 120 * MIN);
|
||||
|
||||
// Guide cache extends well beyond the query range
|
||||
const cacheEnd = BASE + 8 * HOUR;
|
||||
|
||||
it('includes a program that starts before and ends within the range (ends in range)', async () => {
|
||||
const channel = makeChannelOrm();
|
||||
// Starts 30 min before range, ends 30 min into range
|
||||
const program = makeGuideItem(BASE + 30 * MIN, 60 * MIN);
|
||||
const service = makeService();
|
||||
seedCache(service, channel, [program], cacheEnd);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
channel.uuid,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0]).toBe(program);
|
||||
});
|
||||
|
||||
it('includes a program that starts within the range and ends after (begins in range)', async () => {
|
||||
const channel = makeChannelOrm();
|
||||
// Starts 90 min into session (30 min into range), ends 30 min past range
|
||||
const program = makeGuideItem(BASE + 90 * MIN, 60 * MIN);
|
||||
const service = makeService();
|
||||
seedCache(service, channel, [program], cacheEnd);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
channel.uuid,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0]).toBe(program);
|
||||
});
|
||||
|
||||
it('includes a program entirely within the range', async () => {
|
||||
const channel = makeChannelOrm();
|
||||
// Starts 70 min, ends 80 min — entirely within [60, 120)
|
||||
const program = makeGuideItem(BASE + 70 * MIN, 10 * MIN);
|
||||
const service = makeService();
|
||||
seedCache(service, channel, [program], cacheEnd);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
channel.uuid,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0]).toBe(program);
|
||||
});
|
||||
|
||||
it('includes a program that starts exactly at the range start', async () => {
|
||||
const channel = makeChannelOrm();
|
||||
const program = makeGuideItem(BASE + 60 * MIN, 30 * MIN);
|
||||
const service = makeService();
|
||||
seedCache(service, channel, [program], cacheEnd);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
channel.uuid,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes a program that ends exactly at the range end', async () => {
|
||||
const channel = makeChannelOrm();
|
||||
// Starts 30 min before range end, ends exactly at range end
|
||||
const program = makeGuideItem(BASE + 30 * MIN, 90 * MIN);
|
||||
const service = makeService();
|
||||
seedCache(service, channel, [program], cacheEnd);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
channel.uuid,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes a program entirely before the range', async () => {
|
||||
const channel = makeChannelOrm();
|
||||
// Starts 0, ends 30 min — before [60, 120)
|
||||
const program = makeGuideItem(BASE, 30 * MIN);
|
||||
const service = makeService();
|
||||
seedCache(service, channel, [program], cacheEnd);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
channel.uuid,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes a program that ends exactly at the range start', async () => {
|
||||
const channel = makeChannelOrm();
|
||||
// Ends exactly at rangeStart (BASE + 60 MIN)
|
||||
const program = makeGuideItem(BASE, 60 * MIN);
|
||||
const service = makeService();
|
||||
seedCache(service, channel, [program], cacheEnd);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
channel.uuid,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes a program that spans the entire range (neither begins nor ends in range)', async () => {
|
||||
const channel = makeChannelOrm();
|
||||
// Starts 30 min before range, ends 30 min after range — spans [60, 120)
|
||||
const program = makeGuideItem(BASE + 30 * MIN, 120 * MIN);
|
||||
const service = makeService();
|
||||
seedCache(service, channel, [program], cacheEnd);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
channel.uuid,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes a program entirely after the range', async () => {
|
||||
const channel = makeChannelOrm();
|
||||
// Starts at range end or beyond
|
||||
const program = makeGuideItem(BASE + 120 * MIN, 30 * MIN);
|
||||
const service = makeService();
|
||||
seedCache(service, channel, [program], cacheEnd);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
channel.uuid,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns only the programs that begin or end in range from a mixed list', async () => {
|
||||
const channel = makeChannelOrm();
|
||||
// wholly before range
|
||||
const beforeRange = makeGuideItem(BASE, 30 * MIN);
|
||||
// ends in range
|
||||
const endsInRange = makeGuideItem(BASE + 30 * MIN, 60 * MIN);
|
||||
// starts in range, ends after
|
||||
const startsInRange = makeGuideItem(BASE + 90 * MIN, 60 * MIN);
|
||||
// spans range — excluded
|
||||
const spansRange = makeGuideItem(BASE + 30 * MIN, 120 * MIN);
|
||||
// wholly after range
|
||||
const afterRange = makeGuideItem(BASE + 150 * MIN, 30 * MIN);
|
||||
|
||||
const service = makeService();
|
||||
seedCache(
|
||||
service,
|
||||
channel,
|
||||
[beforeRange, endsInRange, startsInRange, spansRange, afterRange],
|
||||
cacheEnd,
|
||||
);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
channel.uuid,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(endsInRange);
|
||||
expect(result).toContain(startsInRange);
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown channel', async () => {
|
||||
const service = makeService();
|
||||
// Seed a different channel so cachedGuide is non-empty
|
||||
const channel = makeChannelOrm();
|
||||
seedCache(service, channel, [], cacheEnd);
|
||||
|
||||
const result = await service.getChannelLineup(
|
||||
'nonexistent-id',
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCachedChannel', () => {
|
||||
it('immediately removes the channel from XMLTV output', async () => {
|
||||
const channelA = makeChannelWithLineup({ number: 1, name: 'Channel A' });
|
||||
|
||||
@@ -274,16 +274,16 @@ export class TVGuideService {
|
||||
const { programs } = channelAndLineup;
|
||||
|
||||
return seq.collect(programs, (program) => {
|
||||
const startTime = Math.max(program.startTimeMs, beginningTimeMs);
|
||||
const stopTime = Math.min(
|
||||
program.startTimeMs + program.lineupItem.durationMs,
|
||||
endTimeMs,
|
||||
);
|
||||
|
||||
if (startTime < stopTime) {
|
||||
const programEndTime =
|
||||
program.startTimeMs + program.lineupItem.durationMs;
|
||||
const startsInRange =
|
||||
program.startTimeMs >= beginningTimeMs &&
|
||||
program.startTimeMs < endTimeMs;
|
||||
const endsInRange =
|
||||
programEndTime > beginningTimeMs && programEndTime <= endTimeMs;
|
||||
if (startsInRange || endsInRange) {
|
||||
return program;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user