const axios = require('axios') const dayjs = require('dayjs') const doFetch = require('@ntlab/sfetch') const debug = require('debug')('site:sky.com') const fs = require('fs') const path = require('path') const utc = require('dayjs/plugin/utc') dayjs.extend(utc) doFetch.setCheckResult(false).setDebugger(debug) const ATLANTIS_API_ENDPOINT = 'https://atlantis.epgsky.com/as' const CHANNELS_FILE = path.join(__dirname, 'sky.com.channels.xml') const HAWK_API_ENDPOINT = 'https://awk.epgsky.com/hawk/linear' const LANG_BY_TERRITORY = { DE: 'de', GB: 'en', IT: 'it' } const MAX_SIDS_PER_REQUEST = 20 const GRAB_START_DATE = dayjs.utc(process.env.CURR_DATE || new Date().toISOString()).startOf('d') const eventIds = new Set() const scheduleRequests = new Map() let scheduleBatches module.exports = { site: 'sky.com', days: 2, request: { headers({ channel }) { const { territory } = parseSiteId(channel.site_id) return isUhdChannel(channel) ? getAtlantisHeaders(territory) : getHeaders(territory) }, cache: { ttl: 24 * 60 * 60 * 1000, // 1 day vary: ['X-SkyOTT-Territory'] } }, url({ date, channel }) { const { sid } = parseSiteId(channel.site_id) if (isUhdChannel(channel)) { return `${ATLANTIS_API_ENDPOINT}/schedule/${date.format('YYYYMMDD')}/${sid}` } const sids = getScheduleBatch(channel.site_id) || [sid] return `${HAWK_API_ENDPOINT}/schedule/${date.format('YYYYMMDD')}/${sids.join(',')}` }, async parser({ content, channel, date, config }) { const programs = [] 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) } } 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 }) } } return programs }, async channels() { const territories = Object.entries(LANG_BY_TERRITORY) const queue = territories.map(([territory, lang]) => ({ type: 'regions', lang, territory, url: `${HAWK_API_ENDPOINT}/regions`, params: { headers: getHeaders(territory) } })) 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 } ]) ) 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 } const regionId = `${region.bouquetId}/${region.subBouquetId}` const key = `${request.territory}:${regionId}` if (regions.has(key)) continue regions.add(key) const serviceRequests = [ { source: 'hawk', url: `${HAWK_API_ENDPOINT}/services/${regionId}`, headers: getHeaders(request.territory) }, { source: 'atlantis', url: `${ATLANTIS_API_ENDPOINT}/services/${regionId}`, headers: getAtlantisHeaders(request.territory) } ] 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 } }) } } 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 if (request.source === 'atlantis' && service.schedule !== true) continue const siteId = `${request.territory}#${service.sid}` if (!channels.has(siteId) || request.source === 'hawk') { const isNew = !channels.has(siteId) channels.set(siteId, { lang: request.lang, site_id: siteId, name: service.t }) if (isNew) state.channels++ } } }) 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(', ') throw new Error(`Unable to load complete Sky channel list: ${reasons || 'no channels found'}`) } return [...channels.values()] } } function getHeaders(territory) { return { 'X-SkyOTT-Territory': territory } } function getAtlantisHeaders(territory) { return { 'X-SkyOTT-Proposition': 'SKYQ', 'X-SkyOTT-Provider': 'SKY', ...getHeaders(territory) } } function getEventId(channel, event) { return `${channel.site_id}:${event.eid}` } function getGrabRange(config) { const configuredDays = Number(config?.days) const days = Number.isInteger(configuredDays) && configuredDays > 0 ? configuredDays : module.exports.days 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, { headers: module.exports.request.headers({ channel }) }) .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()]) ) for (const match of channelsXml.matchAll(/]*)>([^<]*)<\/channel>/g)) { const siteId = match[1].match(/\bsite_id="([^"]+)"/)?.[1] if (!siteId || isUhdChannel({ name: match[2] })) continue const { sid, territory } = parseSiteId(siteId) 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) } } } } function isUhdChannel(channel) { return /uhd|ultra\s*hd|4k/i.test(`${channel?.name || ''} ${channel?.xmltv_id || ''}`) } 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 "#" 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 } }