mirror of
https://github.com/iptv-org/epg
synced 2026-09-06 03:49:49 -04:00
355 lines
9.5 KiB
JavaScript
355 lines
9.5 KiB
JavaScript
const axios = require('axios')
|
|
const crypto = require('crypto')
|
|
const dayjs = require('dayjs')
|
|
const utc = require('dayjs/plugin/utc')
|
|
const customParseFormat = require('dayjs/plugin/customParseFormat')
|
|
|
|
dayjs.extend(utc)
|
|
dayjs.extend(customParseFormat)
|
|
|
|
const MANIFEST_URL = 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest'
|
|
const DEVICE_MODEL = 'WEB2_FTV'
|
|
const PORTAL = 'release'
|
|
const SUBSCRIBER_TYPE = 'FTV_FREEMIUM_DT'
|
|
const CHANNEL_PAGE_SIZE = 100
|
|
const CHANNEL_PAGE_LIMIT = 1000
|
|
|
|
const FALLBACK_MPX = Object.freeze({
|
|
accountPid: 'mdeprod',
|
|
locationIdUri: 'http://data.entertainment.tv.theplatform.eu/entertainment/data/Location/245991976396',
|
|
feeds: {
|
|
allChannelSchedulesFeed:
|
|
'https://feed.entertainment.tv.theplatform.eu/f/{MpxAccountPid}/{MpxAccountPid}-all-channel-schedules',
|
|
allChannelStationsFeed:
|
|
'https://feed.entertainment.tv.theplatform.eu/f/{MpxAccountPid}/{MpxAccountPid}-channel-stations-main'
|
|
}
|
|
})
|
|
|
|
let session
|
|
let manifestPromise
|
|
|
|
module.exports = {
|
|
site: 'www.magenta.tv',
|
|
days: 2,
|
|
request: {
|
|
cache: {
|
|
ttl: 60 * 60 * 1000 // 1 hour
|
|
}
|
|
},
|
|
async url({ channel, date }) {
|
|
const mpx = await getMpxConfig()
|
|
const currentSession = getSession()
|
|
const window = getUtcWindow(date)
|
|
|
|
return buildScheduleUrl({
|
|
mpx,
|
|
session: currentSession,
|
|
siteIds: [channel.site_id],
|
|
window
|
|
})
|
|
},
|
|
async parser({ content, channel }) {
|
|
return parseScheduleResponse(content, channel)
|
|
},
|
|
async channels() {
|
|
const mpx = await getMpxConfig()
|
|
const currentSession = getSession()
|
|
const channels = []
|
|
|
|
for (let start = 1; start <= CHANNEL_PAGE_LIMIT; start += CHANNEL_PAGE_SIZE) {
|
|
const entries = await getChannelEntries({
|
|
mpx,
|
|
session: currentSession,
|
|
start,
|
|
end: start + CHANNEL_PAGE_SIZE - 1
|
|
})
|
|
|
|
if (!entries.length) break
|
|
|
|
entries.forEach(entry => {
|
|
const channel = parseChannel(entry)
|
|
if (channel) channels.push(channel)
|
|
})
|
|
|
|
if (entries.length < CHANNEL_PAGE_SIZE) break
|
|
}
|
|
|
|
return channels
|
|
}
|
|
}
|
|
|
|
function createSession() {
|
|
return {
|
|
deviceId: crypto.randomUUID(),
|
|
sessionId: crypto.randomUUID()
|
|
}
|
|
}
|
|
|
|
function getSession() {
|
|
if (!session) {
|
|
session = createSession()
|
|
}
|
|
|
|
return session
|
|
}
|
|
|
|
function buildCid(currentSession) {
|
|
return `${currentSession.sessionId}::${crypto.randomUUID()}`
|
|
}
|
|
|
|
function getManifestRequestConfig(currentSession) {
|
|
return {
|
|
headers: {
|
|
'X-DT-Call-ID': crypto.randomUUID(),
|
|
'X-DT-Session-ID': currentSession.sessionId
|
|
},
|
|
params: {
|
|
deviceId: currentSession.deviceId,
|
|
deviceModel: DEVICE_MODEL,
|
|
portal: PORTAL,
|
|
subscriberType: SUBSCRIBER_TYPE,
|
|
$redirect: false,
|
|
sid: currentSession.sessionId
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getManifest() {
|
|
if (!manifestPromise) {
|
|
manifestPromise = axios
|
|
.get(MANIFEST_URL, getManifestRequestConfig(getSession()))
|
|
.then(r => r.data)
|
|
.catch(() => null)
|
|
}
|
|
|
|
return manifestPromise
|
|
}
|
|
|
|
async function getMpxConfig() {
|
|
const manifest = await getManifest()
|
|
const manifestMpx = manifest && manifest.mpx ? manifest.mpx : {}
|
|
|
|
return {
|
|
...FALLBACK_MPX,
|
|
...manifestMpx,
|
|
feeds: {
|
|
...FALLBACK_MPX.feeds,
|
|
...(manifestMpx.feeds || {})
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getChannelEntries({ mpx, session, start, end }) {
|
|
const url = buildChannelFeedUrl({ mpx, session, start, end })
|
|
const data = await axios
|
|
.get(url)
|
|
.then(r => r.data)
|
|
.catch(() => null)
|
|
|
|
return Array.isArray(data && data.entries) ? data.entries : []
|
|
}
|
|
|
|
function buildChannelFeedUrl({ mpx, session, start, end }) {
|
|
const url = new URL(resolveFeedTemplate(mpx.feeds.allChannelStationsFeed, mpx.accountPid))
|
|
|
|
url.searchParams.set('lang', 'short-de')
|
|
url.searchParams.set('sort', 'dt$displayChannelNumber')
|
|
url.searchParams.set('range', `${start}-${end}`)
|
|
url.searchParams.set('cid', buildCid(session))
|
|
|
|
return url.toString()
|
|
}
|
|
|
|
function buildScheduleUrl({ mpx, session, siteIds, window }) {
|
|
const url = new URL(resolveFeedTemplate(mpx.feeds.allChannelSchedulesFeed, mpx.accountPid))
|
|
|
|
url.searchParams.set('byId', siteIds.join('|'))
|
|
url.searchParams.set('byListingTime', window)
|
|
url.searchParams.set('byLocationId', mpx.locationIdUri)
|
|
url.searchParams.set('cid', buildCid(session))
|
|
|
|
return url.toString()
|
|
}
|
|
|
|
function resolveFeedTemplate(template, accountPid) {
|
|
return template.replaceAll('{MpxAccountPid}', accountPid)
|
|
}
|
|
|
|
function getUtcWindow(date) {
|
|
const start = date.utc().startOf('day')
|
|
const end = start.add(1, 'day')
|
|
|
|
return `${start.toISOString()}~${end.toISOString()}`
|
|
}
|
|
|
|
function parseScheduleResponse(content, channel) {
|
|
const data = parseJson(content)
|
|
const entries = Array.isArray(data && data.entries) ? data.entries : []
|
|
const targetSiteId = channel && channel.site_id ? String(channel.site_id) : null
|
|
const programs = []
|
|
|
|
entries.forEach(entry => {
|
|
if (targetSiteId && extractNumericId(entry.id) !== targetSiteId) return
|
|
if (!Array.isArray(entry.listings)) return
|
|
|
|
entry.listings.forEach(listing => {
|
|
const program = parseProgramme(entry, listing)
|
|
if (program) programs.push(program)
|
|
})
|
|
})
|
|
|
|
return programs
|
|
}
|
|
|
|
function parseChannel(entry) {
|
|
if (!entry || entry['dt$isRadio']) return null
|
|
|
|
const station = getFirstStation(entry)
|
|
const siteId = extractNumericId(entry.id)
|
|
const name = station && station.title ? station.title : entry.title
|
|
|
|
if (!station || !siteId || !name) return null
|
|
|
|
return {
|
|
lang: 'de',
|
|
site_id: siteId,
|
|
name
|
|
}
|
|
}
|
|
|
|
function parseProgramme(entry, listing) {
|
|
if (
|
|
!listing ||
|
|
!listing.program ||
|
|
listing.startTime === null ||
|
|
listing.startTime === undefined ||
|
|
listing.endTime === null ||
|
|
listing.endTime === undefined
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const program = listing.program
|
|
const programInfo = parseProgramInfo(listing['dt$programInfo'])
|
|
const title = program.title
|
|
|
|
if (!title) return null
|
|
|
|
const parsed = {
|
|
title,
|
|
description: program.description || null,
|
|
category: parseCategories(program.tags),
|
|
sub_title: parseSubTitle(program, listing),
|
|
rating: parseRating(program.ratings),
|
|
season: normalizeNumber(program.tvSeasonNumber ?? programInfo.tvSeasonNumber),
|
|
episode: normalizeNumber(
|
|
program.tvSeasonEpisodeNumber ??
|
|
programInfo.tvSeasonEpisodeNumber ??
|
|
programInfo.seriesEpisodeNumber
|
|
),
|
|
image: parseProgramImage(program),
|
|
icon: parseProgramImage(program),
|
|
start: dayjs(Number(listing.startTime)),
|
|
stop: dayjs(Number(listing.endTime)),
|
|
country: parseCountry(program['dt$countries']),
|
|
date: program.year ? String(program.year) : null
|
|
}
|
|
|
|
if (parsed.image === null) delete parsed.image
|
|
if (parsed.icon === null) delete parsed.icon
|
|
if (parsed.category === null) delete parsed.category
|
|
if (parsed.sub_title === null) delete parsed.sub_title
|
|
if (parsed.rating === null) delete parsed.rating
|
|
if (parsed.season === null) delete parsed.season
|
|
if (parsed.episode === null) delete parsed.episode
|
|
if (parsed.country === null) delete parsed.country
|
|
if (parsed.date === null) delete parsed.date
|
|
|
|
return parsed
|
|
}
|
|
|
|
function parseSubTitle(program, listing) {
|
|
if (program.secondaryTitle && program.secondaryTitle !== program.title) {
|
|
return program.secondaryTitle
|
|
}
|
|
|
|
if (listing['dt$seriesTitle'] && listing['dt$seriesTitle'] !== program.title) {
|
|
return listing['dt$seriesTitle']
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function parseCategories(tags) {
|
|
if (!Array.isArray(tags)) return null
|
|
|
|
const categories = tags
|
|
.filter(tag => ['category', 'genre-primary', 'genre-secondary'].includes(tag.scheme) && tag.title)
|
|
.map(tag => tag.title)
|
|
|
|
return categories.length ? [...new Set(categories)] : null
|
|
}
|
|
|
|
function parseRating(ratings) {
|
|
if (!Array.isArray(ratings)) return null
|
|
|
|
const rating = ratings.find(item => item && item.rating && item.rating !== 'UNKNOWN')
|
|
if (!rating) return null
|
|
|
|
return {
|
|
system: rating.scheme || 'magenta',
|
|
value: rating.rating
|
|
}
|
|
}
|
|
|
|
function parseProgramInfo(value) {
|
|
if (!value || typeof value !== 'string') return {}
|
|
|
|
try {
|
|
return JSON.parse(value.replaceAll("'", '"'))
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function parseProgramImage(program) {
|
|
if (!program || !program.thumbnails) return null
|
|
|
|
const thumbnails = Object.values(program.thumbnails)
|
|
.filter(thumbnail => thumbnail && thumbnail.url)
|
|
.sort((a, b) => (b.width || 0) * (b.height || 0) - (a.width || 0) * (a.height || 0))
|
|
|
|
return thumbnails[0] ? thumbnails[0].url : null
|
|
}
|
|
|
|
function parseCountry(value) {
|
|
if (!value || typeof value !== 'string') return null
|
|
return value.toUpperCase()
|
|
}
|
|
|
|
function getFirstStation(entry) {
|
|
if (!entry || !entry.stations || typeof entry.stations !== 'object') return null
|
|
return Object.values(entry.stations)[0] || null
|
|
}
|
|
|
|
function extractNumericId(uri) {
|
|
if (!uri || typeof uri !== 'string') return null
|
|
const match = uri.match(/(\d+)(?!.*\d)/)
|
|
return match ? match[1] : null
|
|
}
|
|
|
|
function normalizeNumber(value) {
|
|
return value === null || value === undefined || value === '' ? null : value
|
|
}
|
|
|
|
function parseJson(content) {
|
|
if (!content) return {}
|
|
if (typeof content !== 'string') return content
|
|
|
|
try {
|
|
return JSON.parse(content)
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|