mirror of
https://github.com/iptv-org/epg
synced 2026-04-08 11:52:54 -04:00
canalplus.com : optimization & robustness
This commit is contained in:
@@ -5,51 +5,51 @@ const utc = require('dayjs/plugin/utc')
|
||||
dayjs.extend(utc)
|
||||
|
||||
const paths = {
|
||||
ad: 'cpfra/ad',
|
||||
au: 'cpncl/au',
|
||||
bf: 'cpafr/bf',
|
||||
bi: 'cpafr/bi',
|
||||
bj: 'cpafr/bj',
|
||||
bl: 'cpant/bl',
|
||||
cd: 'cpafr/cd',
|
||||
cf: 'cpafr/cf',
|
||||
cg: 'cpafr/cg',
|
||||
ch: 'cpche',
|
||||
ch_de: 'cpchd',
|
||||
ci: 'cpafr/ci',
|
||||
cm: 'cpafr/cm',
|
||||
cv: 'cpafr/cv',
|
||||
dj: 'cpafr/dj',
|
||||
et: 'cpeth/et',
|
||||
fr: 'cpfra',
|
||||
ga: 'cpafr/ga',
|
||||
gf: 'cpant/gf',
|
||||
gh: 'cpafr/gh',
|
||||
gm: 'cpafr/gm',
|
||||
gn: 'cpafr/gn',
|
||||
gp: 'cpafr/gp',
|
||||
gw: 'cpafr/gw',
|
||||
ht: 'cpant/ht',
|
||||
km: 'cpafr/km',
|
||||
mc: 'cpfra/mc',
|
||||
mf: 'cpant/mf',
|
||||
mg: 'cpmdg/mg',
|
||||
ml: 'cpafr/ml',
|
||||
mq: 'cpant/mq',
|
||||
mr: 'cpafr/mr',
|
||||
mu: 'cpmus/mu',
|
||||
nc: 'cpncl/nc',
|
||||
ne: 'cpafr/ne',
|
||||
pf: 'cppyf/pf',
|
||||
pl: 'cppol',
|
||||
re: 'cpreu/re',
|
||||
rw: 'cpafr/rw',
|
||||
sl: 'cpafr/sl',
|
||||
sn: 'cpafr/sn',
|
||||
td: 'cpafr/td',
|
||||
tg: 'cpafr/tg',
|
||||
wf: 'cpncl/wf',
|
||||
yt: 'cpreu/yt',
|
||||
ad: { zone: 'cpfra', location: 'ad' },
|
||||
au: { zone: 'cpncl', location: 'au' },
|
||||
bf: { zone: 'cpafr', location: 'bf' },
|
||||
bi: { zone: 'cpafr', location: 'bi' },
|
||||
bj: { zone: 'cpafr', location: 'bj' },
|
||||
bl: { zone: 'cpant', location: 'bl' },
|
||||
cd: { zone: 'cpafr', location: 'cd' },
|
||||
cf: { zone: 'cpafr', location: 'cf' },
|
||||
cg: { zone: 'cpafr', location: 'cg' },
|
||||
ch: { zone: 'cpche', location: null },
|
||||
ch_de: { zone: 'cpchd', location: null },
|
||||
ci: { zone: 'cpafr', location: 'ci' },
|
||||
cm: { zone: 'cpafr', location: 'cm' },
|
||||
cv: { zone: 'cpafr', location: 'cv' },
|
||||
dj: { zone: 'cpafr', location: 'dj' },
|
||||
et: { zone: 'cpeth', location: 'et' },
|
||||
fr: { zone: null, location: null },
|
||||
ga: { zone: 'cpafr', location: 'ga' },
|
||||
gf: { zone: 'cpant', location: 'gf' },
|
||||
gh: { zone: 'cpafr', location: 'gh' },
|
||||
gm: { zone: 'cpafr', location: 'gm' },
|
||||
gn: { zone: 'cpafr', location: 'gn' },
|
||||
gp: { zone: 'cpafr', location: 'gp' },
|
||||
gw: { zone: 'cpafr', location: 'gw' },
|
||||
ht: { zone: 'cpant', location: 'ht' },
|
||||
km: { zone: 'cpafr', location: 'km' },
|
||||
mc: { zone: 'cpfra', location: 'mc' },
|
||||
mf: { zone: 'cpant', location: 'mf' },
|
||||
mg: { zone: 'cpmdg', location: 'mg' },
|
||||
ml: { zone: 'cpafr', location: 'ml' },
|
||||
mq: { zone: 'cpant', location: 'mq' },
|
||||
mr: { zone: 'cpafr', location: 'mr' },
|
||||
mu: { zone: 'cpmus', location: 'mu' },
|
||||
nc: { zone: 'cpncl', location: 'nc' },
|
||||
ne: { zone: 'cpafr', location: 'ne' },
|
||||
pf: { zone: 'cppyf', location: 'pf' },
|
||||
pl: { zone: null, location: null },
|
||||
re: { zone: 'cpreu', location: 're' },
|
||||
rw: { zone: 'cpafr', location: 'rw' },
|
||||
sl: { zone: 'cpafr', location: 'sl' },
|
||||
sn: { zone: 'cpafr', location: 'sn' },
|
||||
td: { zone: 'cpafr', location: 'td' },
|
||||
tg: { zone: 'cpafr', location: 'tg' },
|
||||
wf: { zone: 'cpncl', location: 'wf' },
|
||||
yt: { zone: 'cpreu', location: 'yt' },
|
||||
}
|
||||
|
||||
const globalHeaders = {
|
||||
@@ -70,7 +70,13 @@ const globalHeaders = {
|
||||
'upgrade-insecure-requests': '1'
|
||||
}
|
||||
|
||||
let canalToken = {}
|
||||
// Per-region token caching to avoid multiple concurrent calls and redundant token fetches
|
||||
const tokenCache = {}
|
||||
const tokenPending = {}
|
||||
|
||||
// ARCOM (ex-CSA) internal ratings mapping (https://www.arcom.fr/se-documenter/ressources-pedagogiques/protection-des-mineurs)
|
||||
// values are negative to be sorted before other ratings if any
|
||||
const CSA_RATING_MAP = { '2': '-10', '3': '-12', '4': '-16', '5': '-18' }
|
||||
|
||||
module.exports = {
|
||||
site: 'canalplus.com',
|
||||
@@ -78,32 +84,42 @@ module.exports = {
|
||||
url: async function ({ channel, date }) {
|
||||
const [region, site_id] = channel.site_id.split('#')
|
||||
const currentRegion = region || 'fr'
|
||||
if(!canalToken[currentRegion] || canalToken.lastRegion !== currentRegion) {
|
||||
canalToken[currentRegion] = await parseToken(currentRegion)
|
||||
canalToken.lastRegion = currentRegion
|
||||
|
||||
if (!tokenCache[currentRegion]) {
|
||||
// Prevents concurrent calls from same region
|
||||
if (!tokenPending[currentRegion]) {
|
||||
tokenPending[currentRegion] = parseToken(currentRegion).then(result => {
|
||||
tokenCache[currentRegion] = result
|
||||
if (Object.prototype.hasOwnProperty.call(tokenPending, currentRegion)) {
|
||||
tokenPending[currentRegion] = undefined
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
await tokenPending[currentRegion]
|
||||
}
|
||||
|
||||
const path = currentRegion === 'pl' ? 'mycanalint' : 'mycanal'
|
||||
const diff = date.diff(dayjs.utc().startOf('d'), 'd')
|
||||
const token = canalToken[currentRegion]?.token
|
||||
const token = tokenCache[currentRegion]?.token
|
||||
|
||||
return `https://hodor.canalplus.pro/api/v2/${path}/channels/${token}/${site_id}/broadcasts/day/${diff}`
|
||||
},
|
||||
request:{
|
||||
request: {
|
||||
headers() {
|
||||
return globalHeaders
|
||||
}
|
||||
},
|
||||
async parser({ content }) {
|
||||
let programs = []
|
||||
const items = parseItems(content)
|
||||
for (let item of items) {
|
||||
const prev = programs[programs.length - 1]
|
||||
const details = await loadProgramDetails(item)
|
||||
const info = parseInfo(details)
|
||||
|
||||
// Parallel loading of all program details
|
||||
const detailsArray = await Promise.all(items.map(loadProgramDetails))
|
||||
|
||||
const programs = items.map((item, i) => {
|
||||
const info = parseInfo(detailsArray[i])
|
||||
const start = parseStart(item)
|
||||
if (prev) prev.stop = start
|
||||
const stop = start.add(1, 'h')
|
||||
programs.push({
|
||||
return {
|
||||
title: item.title,
|
||||
description: parseDescription(info),
|
||||
image: parseImage(info),
|
||||
@@ -115,94 +131,82 @@ module.exports = {
|
||||
date: parseDate(info),
|
||||
rating: parseRating(info),
|
||||
start,
|
||||
stop
|
||||
})
|
||||
stop: null
|
||||
}
|
||||
})
|
||||
|
||||
// Sort programs by start time and set stop time of each program to the start time of the next one
|
||||
for (let i = 0; i < programs.length - 1; i++) {
|
||||
programs[i].stop = programs[i + 1].start
|
||||
}
|
||||
|
||||
// Last program: fallback +1h if there is no next program
|
||||
const last = programs[programs.length - 1]
|
||||
if (last && last.start) {
|
||||
last.stop = last.start.add(1, 'h')
|
||||
}
|
||||
|
||||
return programs
|
||||
},
|
||||
async channels({ country }) {
|
||||
const { zone, location } = paths[country] || {}
|
||||
const pathSegment = location ? `${zone}/${location}` : zone || country
|
||||
const url = `https://secure-webtv-static.canal-plus.com/metadata/${pathSegment}/all/v2.2/globalchannels.json`
|
||||
|
||||
let channels = []
|
||||
const path = paths[country]
|
||||
const url = `https://secure-webtv-static.canal-plus.com/metadata/${path}/all/v2.2/globalchannels.json`
|
||||
const data = await axios
|
||||
.get(url)
|
||||
.then(r => r.data)
|
||||
.catch(console.log)
|
||||
|
||||
data.channels.forEach(channel => {
|
||||
const site_id = country === 'fr' ? `#${channel.id}` : `${country}#${channel.id}`
|
||||
|
||||
if (channel.name === '.') return
|
||||
|
||||
channels.push({
|
||||
return data.channels
|
||||
.filter(channel => channel.name !== '.')
|
||||
.map(channel => ({
|
||||
lang: 'fr',
|
||||
site_id,
|
||||
site_id: country === 'fr' ? `#${channel.id}` : `${country}#${channel.id}`,
|
||||
name: channel.name
|
||||
})
|
||||
})
|
||||
|
||||
return channels
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function parseToken(country) {
|
||||
// for France, query hodor w/ path mycanal
|
||||
// for Poland, query hodor w/ path mycanalint
|
||||
let url
|
||||
if (country !== 'fr' && country !== 'pl') {
|
||||
// Should work for overseas territories
|
||||
const path = paths[country]
|
||||
const offerZone = path.split('/')[0]
|
||||
const offerLocation = path.split('/')[1]
|
||||
const data = await axios.get(`https://hodor.canalplus.pro/api/v2/mycanal/authenticate.json/webapp/6.0?experiments=beta-test-one-tv-guide:control&offerZone=${offerZone}&offerLocation=${offerLocation}`, { headers: globalHeaders }
|
||||
).then(r => r.data).catch(console.error)
|
||||
return { country: country, token: data?.token }
|
||||
}
|
||||
switch(country) {
|
||||
// Canal + France
|
||||
case 'fr':
|
||||
url = 'https://hodor.canalplus.pro/api/v2/mycanal/authenticate.json/webapp/6.0?experiments=beta-test-one-tv-guide:control'
|
||||
break
|
||||
// Canal + International (Poland)
|
||||
case 'pl':
|
||||
url = 'https://hodor.canalplus.pro/api/v2/mycanalint/authenticate.json/webapp/6.0?experiments=beta-test-one-tv-guide:control'
|
||||
break
|
||||
}
|
||||
const tokenData = await axios.get(
|
||||
`${url}`,
|
||||
{
|
||||
headers: globalHeaders,
|
||||
timeout: 5000
|
||||
}).then(r => r.data).catch(console.error)
|
||||
const { zone, location } = paths[country] || {}
|
||||
|
||||
canalToken = { country: country, token: tokenData?.token }
|
||||
return tokenData?.token
|
||||
let url
|
||||
if (country === 'fr') {
|
||||
url = 'https://hodor.canalplus.pro/api/v2/mycanal/authenticate.json/webapp/6.0?experiments=beta-test-one-tv-guide:control'
|
||||
} else if (country === 'pl') {
|
||||
url = 'https://hodor.canalplus.pro/api/v2/mycanalint/authenticate.json/webapp/6.0?experiments=beta-test-one-tv-guide:control'
|
||||
} else {
|
||||
url = `https://hodor.canalplus.pro/api/v2/mycanal/authenticate.json/webapp/6.0?experiments=beta-test-one-tv-guide:control&offerZone=${zone}&offerLocation=${location}`
|
||||
}
|
||||
|
||||
const data = await axios
|
||||
.get(url, { headers: globalHeaders, timeout: 5000 })
|
||||
.then(r => r.data)
|
||||
.catch(console.error)
|
||||
|
||||
return { country, token: data?.token }
|
||||
}
|
||||
|
||||
function parseStart(item) {
|
||||
return item && item.startTime ? dayjs(item.startTime) : null
|
||||
return item?.startTime ? dayjs(item.startTime) : null
|
||||
}
|
||||
|
||||
function parseImage(info) {
|
||||
return info ? info.URLImage : null
|
||||
return info?.URLImage ?? null
|
||||
}
|
||||
|
||||
function parseDescription(info) {
|
||||
return info ? info.summary : null
|
||||
return info?.summary ?? null
|
||||
}
|
||||
|
||||
function parseInfo(data) {
|
||||
if (!data || !data.detail || !data.detail.informations) return null
|
||||
|
||||
return data.detail.informations
|
||||
return data?.detail?.informations ?? null
|
||||
}
|
||||
|
||||
async function loadProgramDetails(item) {
|
||||
if (!item.onClick || !item.onClick.URLPage) return {}
|
||||
|
||||
return await axios
|
||||
if (!item?.onClick?.URLPage) return {}
|
||||
return axios
|
||||
.get(item.onClick.URLPage, { headers: globalHeaders })
|
||||
.then(r => r.data)
|
||||
.catch(console.error)
|
||||
@@ -211,40 +215,26 @@ async function loadProgramDetails(item) {
|
||||
function parseItems(content) {
|
||||
const data = JSON.parse(content)
|
||||
if (!data || !Array.isArray(data.timeSlices)) return []
|
||||
|
||||
return data.timeSlices.reduce((acc, curr) => {
|
||||
acc = acc.concat(curr.contents)
|
||||
return acc
|
||||
}, [])
|
||||
return data.timeSlices.flatMap(s => s.contents)
|
||||
}
|
||||
|
||||
function parseCast(info, type) {
|
||||
let people = []
|
||||
if (info && info.personnalities) {
|
||||
const personnalities = info.personnalities.find(i => i.prefix == type)
|
||||
if (!personnalities) return people
|
||||
for (let person of personnalities.personnalitiesList) {
|
||||
people.push(person.title)
|
||||
}
|
||||
}
|
||||
return people
|
||||
if (!info?.personnalities) return []
|
||||
const group = info.personnalities.find(i => i.prefix === type)
|
||||
if (!group) return []
|
||||
return group.personnalitiesList.map(p => p.title)
|
||||
}
|
||||
|
||||
function parseDate(info) {
|
||||
return info && info.productionYear ? info.productionYear : null
|
||||
return info?.productionYear ?? null
|
||||
}
|
||||
|
||||
function parseRating(info) {
|
||||
if (!info || !info.parentalRatings) return null
|
||||
let rating = info.parentalRatings.find(i => i.authority === 'CSA')
|
||||
if (!rating || Array.isArray(rating)) return null
|
||||
if (rating.value === '1') return null
|
||||
if (rating.value === '2') rating.value = '-10'
|
||||
if (rating.value === '3') rating.value = '-12'
|
||||
if (rating.value === '4') rating.value = '-16'
|
||||
if (rating.value === '5') rating.value = '-18'
|
||||
if (!info?.parentalRatings) return null
|
||||
const rating = info.parentalRatings.find(i => i.authority === 'CSA')
|
||||
if (!rating || Array.isArray(rating) || rating.value === '1') return null
|
||||
return {
|
||||
system: rating.authority,
|
||||
value: rating.value
|
||||
value: CSA_RATING_MAP[rating.value] ?? rating.value
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user