Files
iptvnator/libs/shared/m3u-utils/src/lib/catchup.utils.ts
T

156 lines
4.0 KiB
TypeScript

import { Channel, EpgProgram } from 'shared-interfaces';
const XMLTV_TIMESTAMP_PATTERN =
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{2})(\d{2})$/;
type CatchupSupportMode = 'none' | 'source' | 'shift';
export function getM3uArchiveDays(
channel: Pick<Channel, 'tvg' | 'timeshift' | 'catchup'> | null | undefined
): number {
const value = getFirstNonBlankValue(
channel?.catchup?.days,
channel?.timeshift,
channel?.tvg?.rec
);
return Math.max(0, Number(value ?? 0) || 0);
}
export function isM3uCatchupPlaybackSupported(
channel: Channel | null | undefined
): boolean {
return getM3uCatchupSupportMode(channel) !== 'none';
}
export function resolveM3uCatchupUrl(
channel: Channel | null | undefined,
program: Pick<EpgProgram, 'start' | 'startTimestamp'>,
nowTimestampSeconds = Math.floor(Date.now() / 1000)
): string | null {
const supportMode = getM3uCatchupSupportMode(channel);
if (supportMode === 'none') {
return null;
}
const startTimestamp = getEpgProgramTimestampSeconds(
program.start,
program.startTimestamp
);
if (startTimestamp === null) {
return null;
}
const playbackBaseUrl =
supportMode === 'source'
? channel?.catchup?.source
: channel?.url;
if (!playbackBaseUrl?.trim()) {
return null;
}
const normalizedNow =
Number.isFinite(nowTimestampSeconds) && nowTimestampSeconds > 0
? Math.floor(nowTimestampSeconds)
: Math.floor(Date.now() / 1000);
return setCatchupQueryParams(playbackBaseUrl, {
utc: startTimestamp,
lutc: normalizedNow,
});
}
function getM3uCatchupSupportMode(
channel: Channel | null | undefined
): CatchupSupportMode {
if (getM3uArchiveDays(channel) <= 0) {
return 'none';
}
const catchupSource = channel?.catchup?.source?.trim() ?? '';
if (isHttpUrl(catchupSource)) {
return 'source';
}
const streamUrl = channel?.url?.trim() ?? '';
const catchupType = channel?.catchup?.type?.trim().toLowerCase() ?? '';
if (catchupType === 'shift' && isHttpUrl(streamUrl)) {
return 'shift';
}
if (!catchupType && isHttpUrl(streamUrl)) {
return 'shift';
}
return 'none';
}
function getEpgProgramTimestampSeconds(
dateValue: string,
unixTimestampValue?: number | string | null
): number | null {
const unixTimestamp = Number.parseInt(String(unixTimestampValue ?? ''), 10);
if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) {
return unixTimestamp;
}
const parsed = Date.parse(dateValue);
if (Number.isFinite(parsed)) {
return Math.floor(parsed / 1000);
}
const match = dateValue.match(XMLTV_TIMESTAMP_PATTERN);
if (!match) {
return null;
}
const [, year, month, day, hour, minute, offsetHours, offsetMinutes] =
match;
const utcMillis = Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute)
);
const offsetTotalMinutes =
Number(offsetHours) * 60 +
Math.sign(Number(offsetHours)) * Number(offsetMinutes);
return Math.floor((utcMillis - offsetTotalMinutes * 60_000) / 1000);
}
function setCatchupQueryParams(
rawUrl: string,
params: Record<'utc' | 'lutc', number>
): string | null {
try {
const url = new URL(rawUrl.trim());
url.searchParams.set('utc', String(params.utc));
url.searchParams.set('lutc', String(params.lutc));
return url.toString();
} catch {
return null;
}
}
function isHttpUrl(value: string): boolean {
if (!value) {
return false;
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function getFirstNonBlankValue(
...values: Array<string | null | undefined>
): string | undefined {
return values.find(
(value): value is string => value != null && value.trim() !== ''
);
}