canalplus.com : optimization & robustness

This commit is contained in:
theofficialomega
2026-04-06 16:23:08 +02:00
parent 6e8d052bdf
commit b7af7cbd7e

View File

@@ -5,51 +5,51 @@ const utc = require('dayjs/plugin/utc')
dayjs.extend(utc) dayjs.extend(utc)
const paths = { const paths = {
ad: 'cpfra/ad', ad: { zone: 'cpfra', location: 'ad' },
au: 'cpncl/au', au: { zone: 'cpncl', location: 'au' },
bf: 'cpafr/bf', bf: { zone: 'cpafr', location: 'bf' },
bi: 'cpafr/bi', bi: { zone: 'cpafr', location: 'bi' },
bj: 'cpafr/bj', bj: { zone: 'cpafr', location: 'bj' },
bl: 'cpant/bl', bl: { zone: 'cpant', location: 'bl' },
cd: 'cpafr/cd', cd: { zone: 'cpafr', location: 'cd' },
cf: 'cpafr/cf', cf: { zone: 'cpafr', location: 'cf' },
cg: 'cpafr/cg', cg: { zone: 'cpafr', location: 'cg' },
ch: 'cpche', ch: { zone: 'cpche', location: null },
ch_de: 'cpchd', ch_de: { zone: 'cpchd', location: null },
ci: 'cpafr/ci', ci: { zone: 'cpafr', location: 'ci' },
cm: 'cpafr/cm', cm: { zone: 'cpafr', location: 'cm' },
cv: 'cpafr/cv', cv: { zone: 'cpafr', location: 'cv' },
dj: 'cpafr/dj', dj: { zone: 'cpafr', location: 'dj' },
et: 'cpeth/et', et: { zone: 'cpeth', location: 'et' },
fr: 'cpfra', fr: { zone: null, location: null },
ga: 'cpafr/ga', ga: { zone: 'cpafr', location: 'ga' },
gf: 'cpant/gf', gf: { zone: 'cpant', location: 'gf' },
gh: 'cpafr/gh', gh: { zone: 'cpafr', location: 'gh' },
gm: 'cpafr/gm', gm: { zone: 'cpafr', location: 'gm' },
gn: 'cpafr/gn', gn: { zone: 'cpafr', location: 'gn' },
gp: 'cpafr/gp', gp: { zone: 'cpafr', location: 'gp' },
gw: 'cpafr/gw', gw: { zone: 'cpafr', location: 'gw' },
ht: 'cpant/ht', ht: { zone: 'cpant', location: 'ht' },
km: 'cpafr/km', km: { zone: 'cpafr', location: 'km' },
mc: 'cpfra/mc', mc: { zone: 'cpfra', location: 'mc' },
mf: 'cpant/mf', mf: { zone: 'cpant', location: 'mf' },
mg: 'cpmdg/mg', mg: { zone: 'cpmdg', location: 'mg' },
ml: 'cpafr/ml', ml: { zone: 'cpafr', location: 'ml' },
mq: 'cpant/mq', mq: { zone: 'cpant', location: 'mq' },
mr: 'cpafr/mr', mr: { zone: 'cpafr', location: 'mr' },
mu: 'cpmus/mu', mu: { zone: 'cpmus', location: 'mu' },
nc: 'cpncl/nc', nc: { zone: 'cpncl', location: 'nc' },
ne: 'cpafr/ne', ne: { zone: 'cpafr', location: 'ne' },
pf: 'cppyf/pf', pf: { zone: 'cppyf', location: 'pf' },
pl: 'cppol', pl: { zone: null, location: null },
re: 'cpreu/re', re: { zone: 'cpreu', location: 're' },
rw: 'cpafr/rw', rw: { zone: 'cpafr', location: 'rw' },
sl: 'cpafr/sl', sl: { zone: 'cpafr', location: 'sl' },
sn: 'cpafr/sn', sn: { zone: 'cpafr', location: 'sn' },
td: 'cpafr/td', td: { zone: 'cpafr', location: 'td' },
tg: 'cpafr/tg', tg: { zone: 'cpafr', location: 'tg' },
wf: 'cpncl/wf', wf: { zone: 'cpncl', location: 'wf' },
yt: 'cpreu/yt', yt: { zone: 'cpreu', location: 'yt' },
} }
const globalHeaders = { const globalHeaders = {
@@ -70,7 +70,13 @@ const globalHeaders = {
'upgrade-insecure-requests': '1' '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 = { module.exports = {
site: 'canalplus.com', site: 'canalplus.com',
@@ -78,32 +84,42 @@ module.exports = {
url: async function ({ channel, date }) { url: async function ({ channel, date }) {
const [region, site_id] = channel.site_id.split('#') const [region, site_id] = channel.site_id.split('#')
const currentRegion = region || 'fr' const currentRegion = region || 'fr'
if(!canalToken[currentRegion] || canalToken.lastRegion !== currentRegion) {
canalToken[currentRegion] = await parseToken(currentRegion) if (!tokenCache[currentRegion]) {
canalToken.lastRegion = 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 path = currentRegion === 'pl' ? 'mycanalint' : 'mycanal'
const diff = date.diff(dayjs.utc().startOf('d'), 'd') 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}` return `https://hodor.canalplus.pro/api/v2/${path}/channels/${token}/${site_id}/broadcasts/day/${diff}`
}, },
request:{ request: {
headers() { headers() {
return globalHeaders return globalHeaders
} }
}, },
async parser({ content }) { async parser({ content }) {
let programs = []
const items = parseItems(content) const items = parseItems(content)
for (let item of items) {
const prev = programs[programs.length - 1] // Parallel loading of all program details
const details = await loadProgramDetails(item) const detailsArray = await Promise.all(items.map(loadProgramDetails))
const info = parseInfo(details)
const programs = items.map((item, i) => {
const info = parseInfo(detailsArray[i])
const start = parseStart(item) const start = parseStart(item)
if (prev) prev.stop = start return {
const stop = start.add(1, 'h')
programs.push({
title: item.title, title: item.title,
description: parseDescription(info), description: parseDescription(info),
image: parseImage(info), image: parseImage(info),
@@ -115,94 +131,82 @@ module.exports = {
date: parseDate(info), date: parseDate(info),
rating: parseRating(info), rating: parseRating(info),
start, 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 return programs
}, },
async channels({ country }) { 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 const data = await axios
.get(url) .get(url)
.then(r => r.data) .then(r => r.data)
.catch(console.log) .catch(console.log)
data.channels.forEach(channel => { return data.channels
const site_id = country === 'fr' ? `#${channel.id}` : `${country}#${channel.id}` .filter(channel => channel.name !== '.')
.map(channel => ({
if (channel.name === '.') return
channels.push({
lang: 'fr', lang: 'fr',
site_id, site_id: country === 'fr' ? `#${channel.id}` : `${country}#${channel.id}`,
name: channel.name name: channel.name
}) }))
})
return channels
} }
} }
async function parseToken(country) { async function parseToken(country) {
// for France, query hodor w/ path mycanal const { zone, location } = paths[country] || {}
// 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)
canalToken = { country: country, token: tokenData?.token } let url
return tokenData?.token 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) { function parseStart(item) {
return item && item.startTime ? dayjs(item.startTime) : null return item?.startTime ? dayjs(item.startTime) : null
} }
function parseImage(info) { function parseImage(info) {
return info ? info.URLImage : null return info?.URLImage ?? null
} }
function parseDescription(info) { function parseDescription(info) {
return info ? info.summary : null return info?.summary ?? null
} }
function parseInfo(data) { function parseInfo(data) {
if (!data || !data.detail || !data.detail.informations) return null return data?.detail?.informations ?? null
return data.detail.informations
} }
async function loadProgramDetails(item) { async function loadProgramDetails(item) {
if (!item.onClick || !item.onClick.URLPage) return {} if (!item?.onClick?.URLPage) return {}
return axios
return await axios
.get(item.onClick.URLPage, { headers: globalHeaders }) .get(item.onClick.URLPage, { headers: globalHeaders })
.then(r => r.data) .then(r => r.data)
.catch(console.error) .catch(console.error)
@@ -211,40 +215,26 @@ async function loadProgramDetails(item) {
function parseItems(content) { function parseItems(content) {
const data = JSON.parse(content) const data = JSON.parse(content)
if (!data || !Array.isArray(data.timeSlices)) return [] if (!data || !Array.isArray(data.timeSlices)) return []
return data.timeSlices.flatMap(s => s.contents)
return data.timeSlices.reduce((acc, curr) => {
acc = acc.concat(curr.contents)
return acc
}, [])
} }
function parseCast(info, type) { function parseCast(info, type) {
let people = [] if (!info?.personnalities) return []
if (info && info.personnalities) { const group = info.personnalities.find(i => i.prefix === type)
const personnalities = info.personnalities.find(i => i.prefix == type) if (!group) return []
if (!personnalities) return people return group.personnalitiesList.map(p => p.title)
for (let person of personnalities.personnalitiesList) {
people.push(person.title)
}
}
return people
} }
function parseDate(info) { function parseDate(info) {
return info && info.productionYear ? info.productionYear : null return info?.productionYear ?? null
} }
function parseRating(info) { function parseRating(info) {
if (!info || !info.parentalRatings) return null if (!info?.parentalRatings) return null
let rating = info.parentalRatings.find(i => i.authority === 'CSA') const rating = info.parentalRatings.find(i => i.authority === 'CSA')
if (!rating || Array.isArray(rating)) return null if (!rating || Array.isArray(rating) || rating.value === '1') 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'
return { return {
system: rating.authority, system: rating.authority,
value: rating.value value: CSA_RATING_MAP[rating.value] ?? rating.value
} }
} }