mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* 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>
337 lines
10 KiB
TypeScript
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',
|
|
})
|
|
);
|
|
});
|
|
});
|