2026-07-29 18:08:51 +02:00
|
|
|
const axios = require('axios')
|
2025-09-28 17:55:05 +03:00
|
|
|
const dayjs = require('dayjs')
|
|
|
|
|
const doFetch = require('@ntlab/sfetch')
|
|
|
|
|
const debug = require('debug')('site:sky.com')
|
2026-07-29 18:08:51 +02:00
|
|
|
const fs = require('fs')
|
2025-10-21 12:10:57 +01:00
|
|
|
const path = require('path')
|
2026-07-29 18:08:51 +02:00
|
|
|
const utc = require('dayjs/plugin/utc')
|
2025-09-28 17:55:05 +03:00
|
|
|
|
|
|
|
|
dayjs.extend(utc)
|
|
|
|
|
|
2026-06-08 19:04:43 +07:00
|
|
|
doFetch.setCheckResult(false).setDebugger(debug)
|
2025-09-28 17:55:05 +03:00
|
|
|
|
2026-07-29 22:18:15 +02:00
|
|
|
const ATLANTIS_API_ENDPOINT = 'https://atlantis.epgsky.com/as'
|
2026-07-29 18:08:51 +02:00
|
|
|
const CHANNELS_FILE = path.join(__dirname, 'sky.com.channels.xml')
|
2026-07-29 22:18:15 +02:00
|
|
|
const HAWK_API_ENDPOINT = 'https://awk.epgsky.com/hawk/linear'
|
2026-07-29 18:08:51 +02:00
|
|
|
const LANG_BY_TERRITORY = {
|
|
|
|
|
DE: 'de',
|
|
|
|
|
GB: 'en',
|
|
|
|
|
IT: 'it'
|
|
|
|
|
}
|
|
|
|
|
const MAX_SIDS_PER_REQUEST = 20
|
2026-07-29 22:18:15 +02:00
|
|
|
const GRAB_START_DATE = dayjs.utc(process.env.CURR_DATE || new Date().toISOString()).startOf('d')
|
2026-07-29 18:08:51 +02:00
|
|
|
const eventIds = new Set()
|
|
|
|
|
const scheduleRequests = new Map()
|
|
|
|
|
let scheduleBatches
|
2026-06-11 18:02:58 +07:00
|
|
|
|
2025-09-28 17:55:05 +03:00
|
|
|
module.exports = {
|
|
|
|
|
site: 'sky.com',
|
|
|
|
|
days: 2,
|
2026-06-08 19:04:43 +07:00
|
|
|
request: {
|
2026-07-29 18:08:51 +02:00
|
|
|
headers({ channel }) {
|
2026-07-29 22:18:15 +02:00
|
|
|
const { territory } = parseSiteId(channel.site_id)
|
|
|
|
|
|
|
|
|
|
return isUhdChannel(channel) ? getAtlantisHeaders(territory) : getHeaders(territory)
|
2026-07-29 18:08:51 +02:00
|
|
|
},
|
2026-06-08 19:04:43 +07:00
|
|
|
cache: {
|
2026-07-29 18:08:51 +02:00
|
|
|
ttl: 24 * 60 * 60 * 1000, // 1 day
|
|
|
|
|
vary: ['X-SkyOTT-Territory']
|
2026-06-08 19:04:43 +07:00
|
|
|
}
|
|
|
|
|
},
|
2025-09-28 17:55:05 +03:00
|
|
|
url({ date, channel }) {
|
2026-07-29 18:08:51 +02:00
|
|
|
const { sid } = parseSiteId(channel.site_id)
|
2026-07-29 22:18:15 +02:00
|
|
|
if (isUhdChannel(channel)) {
|
|
|
|
|
return `${ATLANTIS_API_ENDPOINT}/schedule/${date.format('YYYYMMDD')}/${sid}`
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 18:08:51 +02:00
|
|
|
const sids = getScheduleBatch(channel.site_id) || [sid]
|
|
|
|
|
|
2026-07-29 22:18:15 +02:00
|
|
|
return `${HAWK_API_ENDPOINT}/schedule/${date.format('YYYYMMDD')}/${sids.join(',')}`
|
2025-09-28 17:55:05 +03:00
|
|
|
},
|
2026-07-29 18:08:51 +02:00
|
|
|
async parser({ content, channel, date, config }) {
|
2025-09-28 17:55:05 +03:00
|
|
|
const programs = []
|
2026-07-29 18:08:51 +02:00
|
|
|
const events = new Map()
|
|
|
|
|
const range = getGrabRange(config)
|
|
|
|
|
|
|
|
|
|
collectEvents(content, channel, events)
|
|
|
|
|
|
|
|
|
|
date = date.startOf('d')
|
|
|
|
|
if (date.isSame(range.lastDate, 'd')) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await getSchedule(channel, date.add(1, 'd'))
|
|
|
|
|
collectEvents(response, channel, events)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
debug('Unable to load the final Sky schedule batch: %s', error.message)
|
2026-06-08 19:04:43 +07:00
|
|
|
}
|
2026-07-29 18:08:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const event of events.values()) {
|
|
|
|
|
const start = dayjs.utc(event.st * 1000)
|
|
|
|
|
const stop = start.add(event.d, 's')
|
|
|
|
|
const eventId = getEventId(channel, event)
|
|
|
|
|
if (
|
|
|
|
|
start.isBefore(range.stop) &&
|
|
|
|
|
stop.isAfter(range.start) &&
|
|
|
|
|
!eventIds.has(eventId)
|
|
|
|
|
) {
|
|
|
|
|
eventIds.add(eventId)
|
|
|
|
|
const image = event.programmeuuid
|
|
|
|
|
? `https://images.metadata.sky.com/pd-image/${event.programmeuuid}/16-9/640`
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
programs.push({
|
|
|
|
|
title: event.t,
|
|
|
|
|
description: event.sy,
|
|
|
|
|
season: event.seasonnumber,
|
|
|
|
|
episode: event.episodenumber,
|
|
|
|
|
start,
|
|
|
|
|
stop,
|
|
|
|
|
icon: image,
|
|
|
|
|
image
|
2026-06-08 19:04:43 +07:00
|
|
|
})
|
2025-09-28 17:55:05 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return programs
|
|
|
|
|
},
|
|
|
|
|
async channels() {
|
2026-07-29 18:08:51 +02:00
|
|
|
const territories = Object.entries(LANG_BY_TERRITORY)
|
|
|
|
|
const queue = territories.map(([territory, lang]) => ({
|
|
|
|
|
type: 'regions',
|
|
|
|
|
lang,
|
|
|
|
|
territory,
|
2026-07-29 22:18:15 +02:00
|
|
|
url: `${HAWK_API_ENDPOINT}/regions`,
|
2026-07-29 18:08:51 +02:00
|
|
|
params: {
|
|
|
|
|
headers: getHeaders(territory)
|
2025-09-28 17:55:05 +03:00
|
|
|
}
|
2025-10-21 12:10:57 +01:00
|
|
|
}))
|
2026-07-29 18:08:51 +02:00
|
|
|
const channels = new Map()
|
|
|
|
|
const regions = new Set()
|
|
|
|
|
const failures = []
|
|
|
|
|
const states = new Map(
|
|
|
|
|
territories.map(([territory]) => [
|
|
|
|
|
territory,
|
|
|
|
|
{
|
|
|
|
|
channels: 0,
|
|
|
|
|
regionsLoaded: false,
|
|
|
|
|
serviceRequests: 0,
|
|
|
|
|
serviceResponses: 0
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
)
|
2025-10-21 12:10:57 +01:00
|
|
|
|
2026-07-29 18:08:51 +02:00
|
|
|
await doFetch(queue, (request, response) => {
|
|
|
|
|
const state = states.get(request.territory)
|
|
|
|
|
if (request.type === 'regions') {
|
|
|
|
|
if (!Array.isArray(response?.regions) || !response.regions.length) {
|
|
|
|
|
failures.push(`${request.territory}: ${request.url}`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.regionsLoaded = true
|
|
|
|
|
for (const region of response.regions) {
|
|
|
|
|
if (region?.bouquetId === undefined || region?.subBouquetId === undefined) {
|
|
|
|
|
failures.push(`${request.territory}: ${request.url} (invalid region)`)
|
|
|
|
|
continue
|
2025-09-28 17:55:05 +03:00
|
|
|
}
|
2026-07-29 18:08:51 +02:00
|
|
|
|
|
|
|
|
const regionId = `${region.bouquetId}/${region.subBouquetId}`
|
|
|
|
|
const key = `${request.territory}:${regionId}`
|
|
|
|
|
if (regions.has(key)) continue
|
|
|
|
|
|
|
|
|
|
regions.add(key)
|
2026-07-29 22:18:15 +02:00
|
|
|
const serviceRequests = [
|
|
|
|
|
{
|
|
|
|
|
source: 'hawk',
|
|
|
|
|
url: `${HAWK_API_ENDPOINT}/services/${regionId}`,
|
2026-07-29 18:08:51 +02:00
|
|
|
headers: getHeaders(request.territory)
|
2026-07-29 22:18:15 +02:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
source: 'atlantis',
|
|
|
|
|
url: `${ATLANTIS_API_ENDPOINT}/services/${regionId}`,
|
|
|
|
|
headers: getAtlantisHeaders(request.territory)
|
2026-07-29 18:08:51 +02:00
|
|
|
}
|
2026-07-29 22:18:15 +02:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
state.serviceRequests += serviceRequests.length
|
|
|
|
|
for (const serviceRequest of serviceRequests) {
|
|
|
|
|
queue.push({
|
|
|
|
|
type: 'services',
|
|
|
|
|
lang: request.lang,
|
|
|
|
|
source: serviceRequest.source,
|
|
|
|
|
territory: request.territory,
|
|
|
|
|
url: serviceRequest.url,
|
|
|
|
|
params: {
|
|
|
|
|
headers: serviceRequest.headers
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-07-29 18:08:51 +02:00
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(response?.services)) {
|
|
|
|
|
failures.push(`${request.territory}: ${request.url}`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.serviceResponses++
|
|
|
|
|
for (const service of response.services) {
|
|
|
|
|
if (service?.sid === undefined) {
|
|
|
|
|
failures.push(`${request.territory}: ${request.url} (invalid service)`)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!service.t) continue
|
2026-07-29 22:18:15 +02:00
|
|
|
if (request.source === 'atlantis' && service.schedule !== true) continue
|
2026-07-29 18:08:51 +02:00
|
|
|
|
|
|
|
|
const siteId = `${request.territory}#${service.sid}`
|
2026-07-29 22:18:15 +02:00
|
|
|
if (!channels.has(siteId) || request.source === 'hawk') {
|
|
|
|
|
const isNew = !channels.has(siteId)
|
2026-07-29 18:08:51 +02:00
|
|
|
channels.set(siteId, {
|
|
|
|
|
lang: request.lang,
|
|
|
|
|
site_id: siteId,
|
|
|
|
|
name: service.t
|
|
|
|
|
})
|
2026-07-29 22:18:15 +02:00
|
|
|
if (isNew) state.channels++
|
2025-09-28 17:55:05 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-29 18:08:51 +02:00
|
|
|
const incomplete = [...states]
|
|
|
|
|
.filter(
|
|
|
|
|
([, state]) =>
|
|
|
|
|
!state.regionsLoaded ||
|
|
|
|
|
!state.serviceRequests ||
|
|
|
|
|
state.serviceResponses !== state.serviceRequests ||
|
|
|
|
|
!state.channels
|
|
|
|
|
)
|
|
|
|
|
.map(([territory]) => territory)
|
|
|
|
|
|
|
|
|
|
if (incomplete.length || failures.length) {
|
|
|
|
|
const reasons = [...new Set([...failures, ...incomplete])].join(', ')
|
2026-07-29 22:18:15 +02:00
|
|
|
throw new Error(`Unable to load complete Sky channel list: ${reasons || 'no channels found'}`)
|
2026-07-29 18:08:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...channels.values()]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getHeaders(territory) {
|
|
|
|
|
return {
|
|
|
|
|
'X-SkyOTT-Territory': territory
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 22:18:15 +02:00
|
|
|
function getAtlantisHeaders(territory) {
|
|
|
|
|
return {
|
|
|
|
|
'X-SkyOTT-Proposition': 'SKYQ',
|
|
|
|
|
'X-SkyOTT-Provider': 'SKY',
|
|
|
|
|
...getHeaders(territory)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 18:08:51 +02:00
|
|
|
function getEventId(channel, event) {
|
|
|
|
|
return `${channel.site_id}:${event.eid}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getGrabRange(config) {
|
|
|
|
|
const configuredDays = Number(config?.days)
|
2026-07-29 22:18:15 +02:00
|
|
|
const days =
|
|
|
|
|
Number.isInteger(configuredDays) && configuredDays > 0 ? configuredDays : module.exports.days
|
2026-07-29 18:08:51 +02:00
|
|
|
const stop = GRAB_START_DATE.add(days, 'd')
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
lastDate: stop.subtract(1, 'd'),
|
|
|
|
|
start: GRAB_START_DATE,
|
|
|
|
|
stop
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSchedule(channel, date) {
|
|
|
|
|
const { territory } = parseSiteId(channel.site_id)
|
|
|
|
|
const url = module.exports.url({ channel, date })
|
|
|
|
|
const key = `${territory}:${url}`
|
|
|
|
|
|
|
|
|
|
if (!scheduleRequests.has(key)) {
|
|
|
|
|
const request = axios
|
|
|
|
|
.get(url, {
|
2026-07-29 22:18:15 +02:00
|
|
|
headers: module.exports.request.headers({ channel })
|
2026-07-29 18:08:51 +02:00
|
|
|
})
|
|
|
|
|
.then(response => response.data)
|
|
|
|
|
.catch(error => {
|
|
|
|
|
scheduleRequests.delete(key)
|
|
|
|
|
throw error
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
scheduleRequests.set(key, request)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return scheduleRequests.get(key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getScheduleBatch(siteId) {
|
|
|
|
|
if (!scheduleBatches) scheduleBatches = loadScheduleBatches()
|
|
|
|
|
|
|
|
|
|
return scheduleBatches.get(siteId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadScheduleBatches() {
|
|
|
|
|
const channelsXml = fs.readFileSync(CHANNELS_FILE, 'utf8')
|
|
|
|
|
const sidsByTerritory = new Map(
|
|
|
|
|
Object.keys(LANG_BY_TERRITORY).map(territory => [territory, new Set()])
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-29 22:18:15 +02:00
|
|
|
for (const match of channelsXml.matchAll(/<channel\b([^>]*)>([^<]*)<\/channel>/g)) {
|
|
|
|
|
const siteId = match[1].match(/\bsite_id="([^"]+)"/)?.[1]
|
|
|
|
|
if (!siteId || isUhdChannel({ name: match[2] })) continue
|
|
|
|
|
|
|
|
|
|
const { sid, territory } = parseSiteId(siteId)
|
2026-07-29 18:08:51 +02:00
|
|
|
sidsByTerritory.get(territory).add(sid)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const batches = new Map()
|
|
|
|
|
for (const [territory, sidSet] of sidsByTerritory) {
|
|
|
|
|
const sids = [...sidSet]
|
|
|
|
|
for (let index = 0; index < sids.length; index += MAX_SIDS_PER_REQUEST) {
|
|
|
|
|
const batch = sids.slice(index, index + MAX_SIDS_PER_REQUEST)
|
|
|
|
|
for (const sid of batch) {
|
|
|
|
|
batches.set(`${territory}#${sid}`, batch)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return batches
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectEvents(content, channel, events) {
|
|
|
|
|
const data = parseContent(content)
|
|
|
|
|
if (!Array.isArray(data?.schedule)) return
|
|
|
|
|
|
|
|
|
|
const { sid } = parseSiteId(channel.site_id)
|
|
|
|
|
for (const schedule of data.schedule) {
|
|
|
|
|
if (String(schedule.sid) !== sid || !Array.isArray(schedule.events)) continue
|
|
|
|
|
|
|
|
|
|
for (const event of schedule.events) {
|
|
|
|
|
if (event?.eid !== undefined && !events.has(event.eid)) {
|
|
|
|
|
events.set(event.eid, event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 22:18:15 +02:00
|
|
|
function isUhdChannel(channel) {
|
|
|
|
|
return /uhd|ultra\s*hd|4k/i.test(`${channel?.name || ''} ${channel?.xmltv_id || ''}`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 18:08:51 +02:00
|
|
|
function parseSiteId(siteId) {
|
|
|
|
|
const [territory, sid, ...extra] = String(siteId || '').split('#')
|
|
|
|
|
if (!LANG_BY_TERRITORY[territory] || !sid || extra.length) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Invalid Sky site_id "${siteId || ''}". Expected "<territory>#<sid>" with territory "DE", "GB" or "IT".`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { sid, territory }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseContent(content) {
|
|
|
|
|
if (!content) return null
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (Buffer.isBuffer(content)) content = content.toString()
|
|
|
|
|
|
|
|
|
|
return typeof content === 'string' ? JSON.parse(content) : content
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
2025-09-28 17:55:05 +03:00
|
|
|
}
|
|
|
|
|
}
|