mirror of
https://github.com/iptv-org/epg
synced 2026-09-10 13:56:51 -04:00
feat: add www.magenta.tv epg grabber
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
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,
|
||||
logo: parseStationLogo(station)
|
||||
}
|
||||
}
|
||||
|
||||
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 parseStationLogo(station) {
|
||||
if (!station || !station.thumbnails) return null
|
||||
|
||||
return (
|
||||
station.thumbnails.stationLogoColored?.url ||
|
||||
station.thumbnails.stationLogo?.url ||
|
||||
station.thumbnails.stationBackground?.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 {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user