Files
iptvnator/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts
T
Salemand4gray e53adcbeaf fix(catchup): parse negative sub-hour XMLTV offsets correctly (#1216)
* fix(catchup): parse negative sub-hour XMLTV offsets correctly

XMLTV timestamps with offsets like "-0030" were treated as UTC because the
hour part "-00" numerically evaluates to -0 and Math.sign(-0) dropped the
minutes' sign. Derive the sign from the offset string instead, so
sub-hour negative offsets shift the catch-up start time correctly.

* test(catchup): cover positive sub-hour XMLTV offsets

---------

Co-authored-by: 4gray <serega05@gmail.com>
2026-07-21 09:08:37 +02:00

337 lines
10 KiB
TypeScript

import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import {
getM3uArchiveDays,
isM3uCatchupPlaybackSupported,
resolveM3uCatchupUrl,
} from './catchup.utils';
import { createPlaylistObject } from './playlist.utils';
describe('catchup.utils', () => {
const baseChannel: Channel = {
id: 'channel-1',
name: 'News One',
url: 'https://streams.example.com/live/channel-1.m3u8',
group: { title: 'News' },
tvg: {
id: 'news-1',
name: 'News One',
url: '',
logo: '',
rec: '3',
},
catchup: {
type: 'shift',
days: '3',
},
timeshift: '3',
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
};
const archivedProgram: EpgProgram = {
start: '2026-04-10T08:00:00.000Z',
stop: '2026-04-10T09:00:00.000Z',
channel: 'news-1',
title: 'Morning News',
desc: null,
category: null,
startTimestamp: 1_775_808_800,
stopTimestamp: 1_775_812_400,
};
it('reports archive days from catchup metadata before legacy fields', () => {
expect(
getM3uArchiveDays({
...baseChannel,
catchup: { type: 'shift', days: '5' },
timeshift: '3',
tvg: { ...baseChannel.tvg, rec: '2' },
})
).toBe(5);
});
it('falls back to tvg-rec when catchup-days and timeshift are blank strings', () => {
expect(
getM3uArchiveDays({
...baseChannel,
catchup: { type: '', days: '' },
timeshift: '',
tvg: { ...baseChannel.tvg, rec: '7' },
})
).toBe(7);
});
it('skips a blank tvg-rec and uses a real timeshift value', () => {
// `tvg-rec=""` is a common parser default; a nullish (`??`) chain would
// stop on it and yield 0 (unbounded window). The non-blank helper skips
// it and returns the real timeshift days.
expect(
getM3uArchiveDays({
...baseChannel,
catchup: undefined,
timeshift: '7',
tvg: { ...baseChannel.tvg, rec: '' },
})
).toBe(7);
});
it('supports legacy same-stream shift playback when catchup type is shift', () => {
expect(isM3uCatchupPlaybackSupported(baseChannel)).toBe(true);
expect(resolveM3uCatchupUrl(baseChannel, archivedProgram, 1_775_820_000))
.toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808800&lutc=1775820000'
);
});
it('supports tvg-rec-only legacy same-stream playback when the channel url is reusable', () => {
const channel: Channel = {
...baseChannel,
tvg: {
...baseChannel.tvg,
rec: '7',
},
catchup: undefined,
timeshift: undefined,
};
expect(isM3uCatchupPlaybackSupported(channel)).toBe(true);
expect(resolveM3uCatchupUrl(channel, archivedProgram, 1_775_820_000))
.toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808800&lutc=1775820000'
);
});
it('rewrites utc and lutc query params on catchup-source urls', () => {
const playbackUrl = resolveM3uCatchupUrl(
{
...baseChannel,
catchup: {
type: 'append',
days: '3',
source:
'https://archive.example.com/catchup.m3u8?utc=1&lutc=2&token=abc',
},
},
archivedProgram,
1_775_820_000
);
expect(playbackUrl).toBe(
'https://archive.example.com/catchup.m3u8?utc=1775808800&lutc=1775820000&token=abc'
);
});
it('returns null when the channel metadata declares an unsupported replay scheme', () => {
expect(
isM3uCatchupPlaybackSupported({
...baseChannel,
catchup: {
type: 'append',
days: '3',
source: '',
},
timeshift: '3',
})
).toBe(false);
expect(
resolveM3uCatchupUrl(
{
...baseChannel,
catchup: {
type: 'append',
days: '3',
source: '',
},
timeshift: '3',
},
archivedProgram
)
).toBeNull();
});
it('returns null for tvg-rec-only channels when the stream url is not http', () => {
expect(
isM3uCatchupPlaybackSupported({
...baseChannel,
url: 'udp://239.0.0.1:1234',
tvg: {
...baseChannel.tvg,
rec: '7',
},
catchup: undefined,
timeshift: undefined,
})
).toBe(false);
expect(
resolveM3uCatchupUrl(
{
...baseChannel,
url: 'udp://239.0.0.1:1234',
tvg: {
...baseChannel.tvg,
rec: '7',
},
catchup: undefined,
timeshift: undefined,
},
archivedProgram
)
).toBeNull();
});
it('returns null when archive days are missing', () => {
expect(
resolveM3uCatchupUrl(
{
...baseChannel,
tvg: { ...baseChannel.tvg, rec: '' },
timeshift: '',
catchup: {
type: 'shift',
days: '',
},
},
archivedProgram
)
).toBeNull();
});
it('falls back to parsing the program start string when the unix timestamp is unavailable', () => {
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 +0000',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808000&lutc=1775820000'
);
});
it('applies negative sub-hour XMLTV offsets such as -0030', () => {
// Regression: Number('-00') === -0, so a Math.sign()-based conversion
// dropped the minutes' sign and treated -0030 as UTC.
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 -0030',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775809800&lutc=1775820000'
);
});
it('applies positive offsets with minutes such as +0530', () => {
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 +0530',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775788200&lutc=1775820000'
);
});
it('applies positive sub-hour XMLTV offsets such as +0030', () => {
// Regression: Number('+00') === 0, so a Math.sign()-based conversion
// dropped the minutes and treated +0030 as UTC.
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 +0030',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775806200&lutc=1775820000'
);
});
it('treats a -0000 offset as UTC', () => {
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 -0000',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808000&lutc=1775820000'
);
});
it('preserves catchup metadata when storing parsed playlist items', () => {
const playlist = createPlaylistObject('Catchup playlist', {
header: {
attrs: {
'x-tvg-url': 'https://example.com/guide.xml',
},
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml"',
},
items: [
{
name: 'News One',
tvg: {
id: 'news-1',
name: 'News One',
url: '',
logo: '',
rec: '3',
},
group: { title: 'News' },
http: {
referrer: '',
'user-agent': '',
},
url: 'https://streams.example.com/live/channel-1.m3u8',
raw: '#EXTINF:-1,News One',
catchup: {
type: 'shift',
source: 'https://archive.example.com/catchup.m3u8',
days: '3',
},
timeshift: '3',
radio: 'false',
},
],
});
expect(playlist.playlist?.items[0]).toEqual(
expect.objectContaining({
catchup: {
type: 'shift',
source: 'https://archive.example.com/catchup.m3u8',
days: '3',
},
timeshift: '3',
radio: 'false',
})
);
});
});