diff --git a/sites/canalplus.com/canalplus.com.config.js b/sites/canalplus.com/canalplus.com.config.js index 228086e7..f3a5cb18 100644 --- a/sites/canalplus.com/canalplus.com.config.js +++ b/sites/canalplus.com/canalplus.com.config.js @@ -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 } -} +} \ No newline at end of file