diff --git a/sites/www.magenta.tv/www.magenta.tv.channels.xml b/sites/www.magenta.tv/www.magenta.tv.channels.xml index 916792045..192f7679e 100644 --- a/sites/www.magenta.tv/www.magenta.tv.channels.xml +++ b/sites/www.magenta.tv/www.magenta.tv.channels.xml @@ -1,407 +1,407 @@ - RTL - Das Erste - RTL - ZDF - SAT.1 - SAT.1 - ProSieben - RTLZWEI - Super RTL - Kabel Eins - KiKA - VOX - ZDFneo - Sport 1 - myTeamTV - Warner TV Serie - phoenix - 13TH STREET - WDR Fernsehen Köln - Warner TV Film - 3sat - Warner TV Comedy - BR Fernsehen Süd - Eurosport 1 - ntv - WELT - VOX - hr-fernsehen - Kabel Eins - RTL Crime - SWR Fernsehen BW - DMAX - ProSieben FUN - NITRO - rbb fernsehen Berlin - WELT - Eurosport 1 - RTLup - National Geographic - Eurosport 2 - ntv - SAT.1 GOLD - MDR-Fernsehen Sachsen - ProSieben - Radio Bremen TV - ARTE - RTLZWEI - SAT.1 emotions - NDR Fernsehen Niedersachsen - SR Fernsehen - Super RTL - CHANNEL21 - NITRO - ÜLKE TV - Schlagerparadies.TV - Magenta Musik 1 - Sky Sport 5 - Sport 5 - myTeamTV - KinoweltTV - Sky Sport Bundesliga 7 - sixx - MagentaSport - Heimatkanal - TLC - Cartoon Network (Sky) - Sky Sport Golf - Sky Sport Premier League - Bibel TV - More Than Sports TV - SAT.1 GOLD - Sky Sport F1 - Sky Sport Bundesliga 1 - Kabel Eins Doku - Sport 18 - myTeamTV - Sky Sport 1 - Warner TV Comedy (Sky) - National Geographic Wild - DF1 - TV8 Int - Jukebox - Shop LC - Sky Sport Mix - TV5MONDE Europe - QVC - Sky Sci-Fi - Sport 3 - myTeamTV - Kabel Eins CLASSICS - VOXup - ARD-alpha - Welt der Wunder - Warner TV Film (Sky) - Magenta Musik 2 - Sky Sport Bundesliga - ProSieben MAXX - Stingray Classica - Sky Sport Bundesliga 4 - SPORTDIGITAL FUSSBALL - Sky Sport Bundesliga 6 - beIN Movies Turk - Sport 17 - myTeamTV - TOGGO plus - ONE - ONE MUSIC TELEVISION - wedotv Movies - Discovery Channel - Habertürk TV - RTLup - RiC - Sky Sport News - Universal TV - RTL Passion - Warner TV Serie (Sky) - Sky Cinema Classics - TeleBom/TeleDom - Romance TV - The HISTORY Channel - Sky Atlantic - OUTtv - Sport 4 - myTeamTV - Sky Sport 3 - Sky Krimi - Espreso - Stingray iConcerts - Sky Sport Tennis - 13TH STREET (Sky) - Sky Sport 9 - Sky Cinema Action - Sport 8 - myTeamTV - Sport 2 - myTeamTV - Sky Sport 2 - AXN Black - Ballermann TV - Cartoon Network - Sky Sport Bundesliga 10 - Show Turk - Sky Sport 6 - BonGusto - Sky Sport Bundesliga 8 - ANIXE HD Serie - Red Bull TV Motorsport - eSportsONE - Animal Planet - Sky Cinema Premiere - Rai 3 - Stingray DJAZZ - Bergblick - Playboy Europe - OstWest - Sky One - iTVN - Kabel Eins Doku - N24 Doku - MagentaTV Info - Comedy Central - Sky Sport Kompakt 1 - Motorvision+ - tagesschau24 - Marco Polo TV - ANIXE+ - CNN International - SPORT1 - HGTV - FREEDOM - TLC - SCHLAGER DELUXE - DMF - Sky Sport Bundesliga 9 - ProSieben MAXX - MTV - Volksmusik.TV - Sky Sport Bundesliga 5 - Sport 11 - myTeamTV - 123.live - SPORT1 - Sport 14 - myTeamTV - Sky Sport Bundesliga 3 - Kinomir - Crime+Investigation - Sky Sport Top Event - Sport 16 - myTeamTV - Nicktoons (Sky) - Euro D - Sky Sport Bundesliga 2 - ZWEI MUSIC TELEVISION - Rai 2 - SWR Fernsehen RP - Show Max - Nick Jr. (Sky) - Penthouse Passion - Sport 7 - myTeamTV - Euronews Deutsch - RTL Living - FOLX MUSIC TELEVISION - France 24 francais - Comedy Central - Sport 12 - myTeamTV - TELE 5 - Blue Hustler - beIN iZ - TOP SERIEN - Rai 1 - Al Jazeera English - Nick Jr. - Asharq News - sonnenklar.TV - N24 Doku - MTV - Nick/Comedy Central+1 - CNN International - Euronews Russki - Red Bull TV - Sky Sport 8 - Spiegel Geschichte - K-TV - Cartoonito - Sky Documentaries - DMAX - Sportdigital1+ - France 24 english - Sky Crime - DELUXE MUSIC - Lust pur - TOGGO plus - VOXup - Sky Sci-Fi (Sky) - Sky Nature - Sky Showcase - wetter.com TV - Sport 9 - myTeamTV - The HISTORY Channel (Sky) - Universal TV (Sky) - AXN White - Sport 13 - myTeamTV - Sky Cinema Family - Sport 15 - myTeamTV - Sport 10 - myTeamTV - Travelxp - sixx - MS Sport - Euronews Italiano - Sport 6 - myTeamTV - TELE 5 - Sky Sport 4 - Sky Sport 7 - Fashion TV - GEO Television - HSE - Nick/Comedy Central+1 - Kanal 7 - auto motor und sport - Curiosity Channel - Eurostar TV - Cartoonito (Sky) - Crime+Investigation (Sky) - Sky Sport 10 - MOMENTS - ZDFinfo - WDR Fernsehen Duisburg - SAT.1 Bayern - SAT.1 Bayern - CNBC International - rbb fernsehen Brandenburg - TVP Polonia - RTL Bremen & Niedersachsen - WDR Fernsehen Siegen - SAT.1 Rheinland-Pfalz und Hessen - NDR Fernsehen Hamburg - RTL Hamburg & Schleswig-Holstein - RTL Hessen - SAT.1 Hamburg und Schleswig-Holstein - MDR-Fernsehen Thüringen - SAT.1 Niedersachsen und Bremen - MDR-Fernsehen Sachsen-Anhalt - WDR Fernsehen Bonn - RTL Hessen - NDR Fernsehen Mecklenburg-Vorpommern - NDR Fernsehen Schleswig-Holstein - RTL Nordrhein-Westfalen - RTL Bremen & Niedersachsen - WDR Fernsehen Essen - SAT.1 Nordrhein-Westfalen - SAT.1 Nordrhein-Westfalen - WDR Fernsehen Dortmund - ERT World - Euronews English - WDR Fernsehen Bielefeld - SAT.1 Niedersachsen und Bremen - WDR Fernsehen Aachen - WDR Fernsehen Wuppertal - RTL Hamburg & Schleswig-Holstein - WDR Fernsehen Düsseldorf - Imearth - RTL Nordrhein-Westfalen - SAT.1 Hamburg und Schleswig-Holstein - SAT.1 Rheinland-Pfalz und Hessen - WDR Fernsehen Münster - BR Fernsehen Nord - France 2 - France 3 - RTL Bayern - RTL Rhein-Neckar - RTL Rhein-Neckar - RTL Bayern - ATV Avrupa - TRT Cocuk - Disney Channel - TV Mainfranken - ProSiebenSat.1 - TV Oberfranken - ems TV - TV Westsachsen - Hamburg 1 - a.tv - tv.ingolstadt - Baden TV Süd - NIEDERBAYERN TV Deggendorf - Straubing - RFO - NIEDERBAYERN TV Passau - TVA Ostbayern - TV Mittelrhein - RFH Regionalfernsehen Harz - MDF.1 Fernsehen - JenaTV - allgäu.tv - münchen.tv - salve.tv - Oberpfalz TV - RTL - OK:TV Mainz - OK54 Trier - OK4 - LAUSITZWELLE - Baden TV - rheinlOKal - Franken Fernsehen - DELUXE MUSIC - Disney Channel - SRF - Rennsteig.TV - HGTV - NRWision - Regio TV - NIEDERBAYERN TV Landshut - Friesischer Rundfunk - OK Weinstraße - Sky Cinema Highlights - France 5 - BBC News - lausitz.tv - altenburg.tv - France 4 - Glück Auf! TV - RBW - Stimmungsgarten TV - RiC.today - Fix&Foxi TV - Hope TV - Travelxp 4K - teltOwkanal - HT SPOR - BLK TV - Lilo.TV - Juwelo - RAN1 - QVC ZWEI - Romance TV (Sky) - National Geographic (Sky) - Jukebox (Sky) - Beate-Uhse.TV (Sky) - Heimatkanal (Sky) - kulturmd - National Geographic Wild (Sky) - GüstrowTV - World of Freesports - Filmgold - KultKrimi - Telenovela ZDF - Landlust TV - Scooore - SYLT1 - MS GOLF 1 - MS GOLF 2 - Baby TV - Spiegel TV - Grjngo - NARUTO - TOP SCI-FI - TOP TRUE CRIME - Royalworld - Terra Mater WILD - Moviedome - Fabella - Shopping Queen - hundkatzemaus - Bauer sucht Frau - Alarm für Cobra 11 / Balko - tv.berlin - ALEX Berlin - L-TV - Leipzig Fernsehen - Studio 47 - Dresden Fernsehen - Chemnitz Fernsehen - Sky Sport Bundesliga - Sky Sport + RTL + Das Erste + RTL + ZDF + SAT.1 + SAT.1 + ProSieben + RTLZWEI + Super RTL + Kabel Eins + KiKA + VOX + ZDFneo + Sport 1 - myTeamTV + Warner TV Serie + phoenix + 13TH STREET + WDR Fernsehen Köln + Warner TV Film + 3sat + Warner TV Comedy + BR Fernsehen Süd + Eurosport 1 + ntv + WELT + VOX + hr-fernsehen + Kabel Eins + RTL Crime + SWR Fernsehen BW + DMAX + ProSieben FUN + NITRO + rbb fernsehen Berlin + WELT + Eurosport 1 + RTLup + National Geographic + Eurosport 2 + ntv + SAT.1 GOLD + MDR-Fernsehen Sachsen + ProSieben + Radio Bremen TV + ARTE + RTLZWEI + SAT.1 emotions + NDR Fernsehen Niedersachsen + SR Fernsehen + Super RTL + CHANNEL21 + NITRO + ÜLKE TV + Schlagerparadies.TV + Magenta Musik 1 + Sky Sport 5 + Sport 5 - myTeamTV + KinoweltTV + Sky Sport Bundesliga 7 + sixx + MagentaSport + Heimatkanal + TLC + Cartoon Network (Sky) + Sky Sport Golf + Sky Sport Premier League + Bibel TV + More Than Sports TV + SAT.1 GOLD + Sky Sport F1 + Sky Sport Bundesliga 1 + Kabel Eins Doku + Sport 18 - myTeamTV + Sky Sport 1 + Warner TV Comedy (Sky) + National Geographic Wild + DF1 + TV8 Int + Jukebox + Shop LC + Sky Sport Mix + TV5MONDE Europe + QVC + Sky Sci-Fi + Sport 3 - myTeamTV + Kabel Eins CLASSICS + VOXup + ARD-alpha + Welt der Wunder + Warner TV Film (Sky) + Magenta Musik 2 + Sky Sport Bundesliga + ProSieben MAXX + Stingray Classica + Sky Sport Bundesliga 4 + SPORTDIGITAL FUSSBALL + Sky Sport Bundesliga 6 + beIN Movies Turk + Sport 17 - myTeamTV + TOGGO plus + ONE + ONE MUSIC TELEVISION + wedotv Movies + Discovery Channel + Habertürk TV + RTLup + RiC + Sky Sport News + Universal TV + RTL Passion + Warner TV Serie (Sky) + Sky Cinema Classics + TeleBom/TeleDom + Romance TV + The HISTORY Channel + Sky Atlantic + OUTtv + Sport 4 - myTeamTV + Sky Sport 3 + Sky Krimi + Espreso + Stingray iConcerts + Sky Sport Tennis + 13TH STREET (Sky) + Sky Sport 9 + Sky Cinema Action + Sport 8 - myTeamTV + Sport 2 - myTeamTV + Sky Sport 2 + AXN Black + Ballermann TV + Cartoon Network + Sky Sport Bundesliga 10 + Show Turk + Sky Sport 6 + BonGusto + Sky Sport Bundesliga 8 + ANIXE HD Serie + Red Bull TV Motorsport + eSportsONE + Animal Planet + Sky Cinema Premiere + Rai 3 + Stingray DJAZZ + Bergblick + Playboy Europe + OstWest + Sky One + iTVN + Kabel Eins Doku + N24 Doku + MagentaTV Info + Comedy Central + Sky Sport Kompakt 1 + Motorvision+ + tagesschau24 + Marco Polo TV + ANIXE+ + CNN International + SPORT1 + HGTV + FREEDOM + TLC + SCHLAGER DELUXE + DMF + Sky Sport Bundesliga 9 + ProSieben MAXX + MTV + Volksmusik.TV + Sky Sport Bundesliga 5 + Sport 11 - myTeamTV + 123.live + SPORT1 + Sport 14 - myTeamTV + Sky Sport Bundesliga 3 + Kinomir + Crime+Investigation + Sky Sport Top Event + Sport 16 - myTeamTV + Nicktoons (Sky) + Euro D + Sky Sport Bundesliga 2 + ZWEI MUSIC TELEVISION + Rai 2 + SWR Fernsehen RP + Show Max + Nick Jr. (Sky) + Penthouse Passion + Sport 7 - myTeamTV + Euronews Deutsch + RTL Living + FOLX MUSIC TELEVISION + France 24 francais + Comedy Central + Sport 12 - myTeamTV + TELE 5 + Blue Hustler + beIN iZ + TOP SERIEN + Rai 1 + Al Jazeera English + Nick Jr. + Asharq News + sonnenklar.TV + N24 Doku + MTV + Nick/Comedy Central+1 + CNN International + Euronews Russki + Red Bull TV + Sky Sport 8 + Spiegel Geschichte + K-TV + Cartoonito + Sky Documentaries + DMAX + Sportdigital1+ + France 24 english + Sky Crime + DELUXE MUSIC + Lust pur + TOGGO plus + VOXup + Sky Sci-Fi (Sky) + Sky Nature + Sky Showcase + wetter.com TV + Sport 9 - myTeamTV + The HISTORY Channel (Sky) + Universal TV (Sky) + AXN White + Sport 13 - myTeamTV + Sky Cinema Family + Sport 15 - myTeamTV + Sport 10 - myTeamTV + Travelxp + sixx + MS Sport + Euronews Italiano + Sport 6 - myTeamTV + TELE 5 + Sky Sport 4 + Sky Sport 7 + Fashion TV + GEO Television + HSE + Nick/Comedy Central+1 + Kanal 7 + auto motor und sport + Curiosity Channel + Eurostar TV + Cartoonito (Sky) + Crime+Investigation (Sky) + Sky Sport 10 + MOMENTS + ZDFinfo + WDR Fernsehen Duisburg + SAT.1 Bayern + SAT.1 Bayern + CNBC International + rbb fernsehen Brandenburg + TVP Polonia + RTL Bremen & Niedersachsen + WDR Fernsehen Siegen + SAT.1 Rheinland-Pfalz und Hessen + NDR Fernsehen Hamburg + RTL Hamburg & Schleswig-Holstein + RTL Hessen + SAT.1 Hamburg und Schleswig-Holstein + MDR-Fernsehen Thüringen + SAT.1 Niedersachsen und Bremen + MDR-Fernsehen Sachsen-Anhalt + WDR Fernsehen Bonn + RTL Hessen + NDR Fernsehen Mecklenburg-Vorpommern + NDR Fernsehen Schleswig-Holstein + RTL Nordrhein-Westfalen + RTL Bremen & Niedersachsen + WDR Fernsehen Essen + SAT.1 Nordrhein-Westfalen + SAT.1 Nordrhein-Westfalen + WDR Fernsehen Dortmund + ERT World + Euronews English + WDR Fernsehen Bielefeld + SAT.1 Niedersachsen und Bremen + WDR Fernsehen Aachen + WDR Fernsehen Wuppertal + RTL Hamburg & Schleswig-Holstein + WDR Fernsehen Düsseldorf + Imearth + RTL Nordrhein-Westfalen + SAT.1 Hamburg und Schleswig-Holstein + SAT.1 Rheinland-Pfalz und Hessen + WDR Fernsehen Münster + BR Fernsehen Nord + France 2 + France 3 + RTL Bayern + RTL Rhein-Neckar + RTL Rhein-Neckar + RTL Bayern + ATV Avrupa + TRT Cocuk + Disney Channel + TV Mainfranken + ProSiebenSat.1 + TV Oberfranken + ems TV + TV Westsachsen + Hamburg 1 + a.tv + tv.ingolstadt + Baden TV Süd + NIEDERBAYERN TV Deggendorf - Straubing + RFO + NIEDERBAYERN TV Passau + TVA Ostbayern + TV Mittelrhein + RFH Regionalfernsehen Harz + MDF.1 Fernsehen + JenaTV + allgäu.tv + münchen.tv + salve.tv + Oberpfalz TV + OK:TV Mainz + OK54 Trier + OK4 + LAUSITZWELLE + Baden TV + rheinlOKal + Franken Fernsehen + DELUXE MUSIC + Disney Channel + SRF + Rennsteig.TV + HGTV + NRWision + Regio TV + NIEDERBAYERN TV Landshut + Friesischer Rundfunk + OK Weinstraße + Sky Cinema Highlights + France 5 + BBC News + lausitz.tv + altenburg.tv + France 4 + Glück Auf! TV + RBW + Stimmungsgarten TV + RiC.today + Fix&Foxi TV + Hope TV + Travelxp 4K + teltOwkanal + HT SPOR + BLK TV + Lilo.TV + Juwelo + RAN1 + QVC ZWEI + Romance TV (Sky) + National Geographic (Sky) + Jukebox (Sky) + Beate-Uhse.TV (Sky) + Heimatkanal (Sky) + kulturmd + National Geographic Wild (Sky) + GüstrowTV + World of Freesports + Filmgold + KultKrimi + Telenovela ZDF + Landlust TV + Scooore + SYLT1 + MS GOLF 1 + MS GOLF 2 + Baby TV + Spiegel TV + Grjngo + NARUTO + TOP SCI-FI + TOP TRUE CRIME + Royalworld + Terra Mater WILD + Moviedome + Fabella + Shopping Queen + hundkatzemaus + Bauer sucht Frau + Alarm für Cobra 11 / Balko + tv.berlin + ALEX Berlin + L-TV + Leipzig Fernsehen + Studio 47 + Dresden Fernsehen + Chemnitz Fernsehen + Sky Sport Bundesliga + Sky Sport + RTL diff --git a/sites/www.magenta.tv/www.magenta.tv.config.js b/sites/www.magenta.tv/www.magenta.tv.config.js index fcaa81377..f653b87c8 100644 --- a/sites/www.magenta.tv/www.magenta.tv.config.js +++ b/sites/www.magenta.tv/www.magenta.tv.config.js @@ -1,366 +1,354 @@ -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 {} - } -} +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 {} + } +} diff --git a/sites/www.magenta.tv/www.magenta.tv.test.js b/sites/www.magenta.tv/www.magenta.tv.test.js index eaf916456..fc415a733 100644 --- a/sites/www.magenta.tv/www.magenta.tv.test.js +++ b/sites/www.magenta.tv/www.magenta.tv.test.js @@ -1,263 +1,262 @@ -const fs = require('fs') -const path = require('path') -const crypto = require('crypto') -const dayjs = require('dayjs') -const utc = require('dayjs/plugin/utc') -const customParseFormat = require('dayjs/plugin/customParseFormat') - -dayjs.extend(customParseFormat) -dayjs.extend(utc) - -jest.mock('axios') - -const fixture = JSON.parse(fs.readFileSync(path.resolve(__dirname, '__data__/content.json'), 'utf8')) -const date = dayjs.utc('2026-05-11', 'YYYY-MM-DD').startOf('d') -const channel = { - site_id: '262270504164', - xmltv_id: 'Sat1.de' -} - -beforeEach(() => { - jest.resetModules() - jest.restoreAllMocks() -}) - -function loadConfig() { - const axios = require('axios') - const config = require('./www.magenta.tv.config.js') - - return { - axios, - ...config - } -} - -it('can generate valid url', async () => { - const { axios, url } = loadConfig() - - jest - .spyOn(crypto, 'randomUUID') - .mockReturnValueOnce('device-id') - .mockReturnValueOnce('session-id') - .mockReturnValueOnce('manifest-call-id') - .mockReturnValueOnce('schedule-call-id') - - axios.get.mockImplementation(url => { - if (url === 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest') { - return Promise.resolve({ data: fixture.manifest }) - } - - return Promise.reject(new Error('unexpected request')) - }) - - const result = await url({ channel, date }) - - expect(result).toBe( - 'https://feed.entertainment.tv.theplatform.eu/f/mdeprod/mdeprod-all-channel-schedules?byId=262270504164&byListingTime=2026-05-11T00%3A00%3A00.000Z%7E2026-05-12T00%3A00%3A00.000Z&byLocationId=http%3A%2F%2Fdata.entertainment.tv.theplatform.eu%2Fentertainment%2Fdata%2FLocation%2F245991976396&cid=session-id%3A%3Aschedule-call-id' - ) - - expect(axios.get).toHaveBeenCalledWith( - 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest', - expect.objectContaining({ - headers: { - 'X-DT-Call-ID': 'manifest-call-id', - 'X-DT-Session-ID': 'session-id' - }, - params: expect.objectContaining({ - deviceId: 'device-id', - sid: 'session-id', - deviceModel: 'WEB2_FTV', - portal: 'release', - subscriberType: 'FTV_FREEMIUM_DT', - $redirect: false - }) - }) - ) -}) - -it('can map a channel feed entry', () => { - const { axios, channels } = loadConfig() - - axios.get.mockImplementation((url) => { - if (url === 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest') { - return Promise.resolve({ data: fixture.manifest }) - } - - if (new URL(url).searchParams.get('range') === '1-100') { - return Promise.resolve({ data: fixture.channels }) - } - - return Promise.resolve({ data: { entries: [] } }) - }) - - return channels().then(result => { - expect(result[0]).toMatchObject({ - lang: 'de', - site_id: '259549736360', - name: 'Das Erste', - logo: 'https://example.com/das-erste-colored.png' - }) - }) -}) - -it('can ignore radio channels', async () => { - const { axios, channels } = loadConfig() - - axios.get.mockImplementation((url) => { - if (url === 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest') { - return Promise.resolve({ data: fixture.manifest }) - } - - if (new URL(url).searchParams.get('range') === '1-100') { - return Promise.resolve({ data: fixture.channels }) - } - - return Promise.resolve({ data: { entries: [] } }) - }) - - const result = await channels() - - expect(result).toHaveLength(1) - expect(result[0].name).toBe('Das Erste') -}) - -it('can paginate channels', async () => { - const { axios, channels } = loadConfig() - - jest - .spyOn(crypto, 'randomUUID') - .mockReturnValueOnce('device-id') - .mockReturnValueOnce('session-id') - .mockReturnValueOnce('manifest-call-id') - .mockImplementation(() => 'call-id') - - const page1 = { - entries: Array.from({ length: 100 }, (_, index) => ({ - id: `http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/${1000 + index}`, - title: `Channel ${index + 1}`, - stations: { - [`station-${index}`]: { - title: `Channel ${index + 1}`, - thumbnails: { - stationLogo: { - url: `https://example.com/${index + 1}.png` - } - } - } - }, - 'dt$isRadio': false - })) - } - const page2 = { - entries: [ - { - id: 'http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/2001', - title: 'Channel 101', - stations: { - 'station-101': { - title: 'Channel 101', - thumbnails: { - stationLogo: { - url: 'https://example.com/101.png' - } - } - } - }, - 'dt$isRadio': false - } - ] - } - - axios.get.mockImplementation((url, options) => { - if (url === 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest') { - return Promise.resolve({ data: fixture.manifest }) - } - - const range = new URL(url).searchParams.get('range') - if (range === '1-100') return Promise.resolve({ data: page1 }) - if (range === '101-200') return Promise.resolve({ data: page2 }) - - return Promise.reject(new Error(`unexpected request ${url} ${JSON.stringify(options)}`)) - }) - - const result = await channels() - - expect(result).toHaveLength(101) - expect(result[0]).toMatchObject({ - site_id: '1000', - name: 'Channel 1' - }) - expect(result[100]).toMatchObject({ - site_id: '2001', - name: 'Channel 101' - }) -}) - -it('can parse response', async () => { - const { parser } = loadConfig() - const result = await parser({ content: JSON.stringify(fixture.schedule), channel }) - const serialized = result.map(program => ({ - ...program, - start: program.start.toJSON(), - stop: program.stop.toJSON() - })) - - expect(serialized).toMatchObject([ - { - start: '2026-05-11T00:10:00.000Z', - stop: '2026-05-11T01:07:00.000Z', - title: 'FBI: Special Crime Unit', - sub_title: 'Aufstand', - description: - 'Eine gigantische Explosion in Brooklyn ruft das FBI auf den Plan. Hinweise deuten auf eine vorsätzliche Fremdeinwirkung hin.', - category: ['200-Serie', 'Krimi', 'Action', 'Thriller'], - season: 8, - episode: 7, - country: 'US', - date: '2025' - } - ]) -}) - -it('can handle empty guide', async () => { - const { parser } = loadConfig() - const result = await parser({ - channel, - content: '{"entries":[{"id":"http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/262270504164","listings":[]}]}' - }) - - expect(result).toMatchObject([]) -}) - -it('can handle listings without program', async () => { - const { parser } = loadConfig() - const result = await parser({ - channel, - content: - '{"entries":[{"id":"http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/262270504164","listings":[{"startTime":1,"endTime":2}]}]}' - }) - - expect(result).toMatchObject([]) -}) - -it('can build a UTC schedule window', async () => { - const { axios, url } = loadConfig() - - jest - .spyOn(crypto, 'randomUUID') - .mockReturnValueOnce('device-id') - .mockReturnValueOnce('session-id') - .mockReturnValueOnce('manifest-call-id') - .mockReturnValueOnce('schedule-call-id') - - axios.get.mockResolvedValue({ data: fixture.manifest }) - - const result = await url({ - channel, - date - }) - - expect(new URL(result).searchParams.get('byListingTime')).toBe( - '2026-05-11T00:00:00.000Z~2026-05-12T00:00:00.000Z' - ) -}) +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const dayjs = require('dayjs') +const utc = require('dayjs/plugin/utc') +const customParseFormat = require('dayjs/plugin/customParseFormat') + +dayjs.extend(customParseFormat) +dayjs.extend(utc) + +jest.mock('axios') + +const fixture = JSON.parse(fs.readFileSync(path.resolve(__dirname, '__data__/content.json'), 'utf8')) +const date = dayjs.utc('2026-05-11', 'YYYY-MM-DD').startOf('d') +const channel = { + site_id: '262270504164', + xmltv_id: 'Sat1.de' +} + +beforeEach(() => { + jest.resetModules() + jest.restoreAllMocks() +}) + +function loadConfig() { + const axios = require('axios') + const config = require('./www.magenta.tv.config.js') + + return { + axios, + ...config + } +} + +it('can generate valid url', async () => { + const { axios, url } = loadConfig() + + jest + .spyOn(crypto, 'randomUUID') + .mockReturnValueOnce('device-id') + .mockReturnValueOnce('session-id') + .mockReturnValueOnce('manifest-call-id') + .mockReturnValueOnce('schedule-call-id') + + axios.get.mockImplementation(url => { + if (url === 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest') { + return Promise.resolve({ data: fixture.manifest }) + } + + return Promise.reject(new Error('unexpected request')) + }) + + const result = await url({ channel, date }) + + expect(result).toBe( + 'https://feed.entertainment.tv.theplatform.eu/f/mdeprod/mdeprod-all-channel-schedules?byId=262270504164&byListingTime=2026-05-11T00%3A00%3A00.000Z%7E2026-05-12T00%3A00%3A00.000Z&byLocationId=http%3A%2F%2Fdata.entertainment.tv.theplatform.eu%2Fentertainment%2Fdata%2FLocation%2F245991976396&cid=session-id%3A%3Aschedule-call-id' + ) + + expect(axios.get).toHaveBeenCalledWith( + 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest', + expect.objectContaining({ + headers: { + 'X-DT-Call-ID': 'manifest-call-id', + 'X-DT-Session-ID': 'session-id' + }, + params: expect.objectContaining({ + deviceId: 'device-id', + sid: 'session-id', + deviceModel: 'WEB2_FTV', + portal: 'release', + subscriberType: 'FTV_FREEMIUM_DT', + $redirect: false + }) + }) + ) +}) + +it('can map a channel feed entry', () => { + const { axios, channels } = loadConfig() + + axios.get.mockImplementation((url) => { + if (url === 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest') { + return Promise.resolve({ data: fixture.manifest }) + } + + if (new URL(url).searchParams.get('range') === '1-100') { + return Promise.resolve({ data: fixture.channels }) + } + + return Promise.resolve({ data: { entries: [] } }) + }) + + return channels().then(result => { + expect(result[0]).toMatchObject({ + lang: 'de', + site_id: '259549736360', + name: 'Das Erste' + }) + }) +}) + +it('can ignore radio channels', async () => { + const { axios, channels } = loadConfig() + + axios.get.mockImplementation((url) => { + if (url === 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest') { + return Promise.resolve({ data: fixture.manifest }) + } + + if (new URL(url).searchParams.get('range') === '1-100') { + return Promise.resolve({ data: fixture.channels }) + } + + return Promise.resolve({ data: { entries: [] } }) + }) + + const result = await channels() + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('Das Erste') +}) + +it('can paginate channels', async () => { + const { axios, channels } = loadConfig() + + jest + .spyOn(crypto, 'randomUUID') + .mockReturnValueOnce('device-id') + .mockReturnValueOnce('session-id') + .mockReturnValueOnce('manifest-call-id') + .mockImplementation(() => 'call-id') + + const page1 = { + entries: Array.from({ length: 100 }, (_, index) => ({ + id: `http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/${1000 + index}`, + title: `Channel ${index + 1}`, + stations: { + [`station-${index}`]: { + title: `Channel ${index + 1}`, + thumbnails: { + stationLogo: { + url: `https://example.com/${index + 1}.png` + } + } + } + }, + 'dt$isRadio': false + })) + } + const page2 = { + entries: [ + { + id: 'http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/2001', + title: 'Channel 101', + stations: { + 'station-101': { + title: 'Channel 101', + thumbnails: { + stationLogo: { + url: 'https://example.com/101.png' + } + } + } + }, + 'dt$isRadio': false + } + ] + } + + axios.get.mockImplementation((url, options) => { + if (url === 'https://prod.dcm.telekom-dienste.de/v1/settings/web-mtv/manifest') { + return Promise.resolve({ data: fixture.manifest }) + } + + const range = new URL(url).searchParams.get('range') + if (range === '1-100') return Promise.resolve({ data: page1 }) + if (range === '101-200') return Promise.resolve({ data: page2 }) + + return Promise.reject(new Error(`unexpected request ${url} ${JSON.stringify(options)}`)) + }) + + const result = await channels() + + expect(result).toHaveLength(101) + expect(result[0]).toMatchObject({ + site_id: '1000', + name: 'Channel 1' + }) + expect(result[100]).toMatchObject({ + site_id: '2001', + name: 'Channel 101' + }) +}) + +it('can parse response', async () => { + const { parser } = loadConfig() + const result = await parser({ content: JSON.stringify(fixture.schedule), channel }) + const serialized = result.map(program => ({ + ...program, + start: program.start.toJSON(), + stop: program.stop.toJSON() + })) + + expect(serialized).toMatchObject([ + { + start: '2026-05-11T00:10:00.000Z', + stop: '2026-05-11T01:07:00.000Z', + title: 'FBI: Special Crime Unit', + sub_title: 'Aufstand', + description: + 'Eine gigantische Explosion in Brooklyn ruft das FBI auf den Plan. Hinweise deuten auf eine vorsätzliche Fremdeinwirkung hin.', + category: ['200-Serie', 'Krimi', 'Action', 'Thriller'], + season: 8, + episode: 7, + country: 'US', + date: '2025' + } + ]) +}) + +it('can handle empty guide', async () => { + const { parser } = loadConfig() + const result = await parser({ + channel, + content: '{"entries":[{"id":"http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/262270504164","listings":[]}]}' + }) + + expect(result).toMatchObject([]) +}) + +it('can handle listings without program', async () => { + const { parser } = loadConfig() + const result = await parser({ + channel, + content: + '{"entries":[{"id":"http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/262270504164","listings":[{"startTime":1,"endTime":2}]}]}' + }) + + expect(result).toMatchObject([]) +}) + +it('can build a UTC schedule window', async () => { + const { axios, url } = loadConfig() + + jest + .spyOn(crypto, 'randomUUID') + .mockReturnValueOnce('device-id') + .mockReturnValueOnce('session-id') + .mockReturnValueOnce('manifest-call-id') + .mockReturnValueOnce('schedule-call-id') + + axios.get.mockResolvedValue({ data: fixture.manifest }) + + const result = await url({ + channel, + date + }) + + expect(new URL(result).searchParams.get('byListingTime')).toBe( + '2026-05-11T00:00:00.000Z~2026-05-12T00:00:00.000Z' + ) +})