mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
Replaces the vertical EPG list with a shared horizontal `app-epg-timeline` ribbon across all live surfaces (M3U player, unified live tab, Xtream, Stalker): zoom, day navigation, short-programme grouping, catch-up/timeshift, and per-state empty views. Backend gains timezone-aware `datetime()` comparisons, unscoped source fallback, non-ASCII candidate matching, and chunked candidate queries. Timeline split into reusable, view-agnostic modules (archive/summary/dialog service/render util/scroll controller) for the future EPG list view. Fixes landed during review: - honor the controlled `selectedDate` input (seed via linkedSignal) - restore ribbon position across collapse/expand - keep the ribbon mounted when scrolling across a gap day - don't trigger block playback on Enter from nested watch/info buttons - don't reset timeshift playback on the 30s now-tick during EPG gaps Greptile 5/5 (safe to merge); Codex clean; CI green.
269 lines
8.3 KiB
TypeScript
269 lines
8.3 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('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',
|
|
})
|
|
);
|
|
});
|
|
});
|