diff --git a/.gitignore b/.gitignore index b561bb470..ce62a05e9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ /guide.xml.gz .secrets /guides/ +/dev/ + +/AGENT.md +/.agent/ # macOS .DS_Store \ No newline at end of file diff --git a/sites/www.magenta.tv/__data__/content.json b/sites/www.magenta.tv/__data__/content.json new file mode 100644 index 000000000..31e6b950a --- /dev/null +++ b/sites/www.magenta.tv/__data__/content.json @@ -0,0 +1,143 @@ +{ + "manifest": { + "mpx": { + "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" + } + } + }, + "channels": { + "startIndex": 1, + "itemsPerPage": 2, + "entryCount": 2, + "entries": [ + { + "id": "http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/259549736360", + "title": "Das Erste HD - Main", + "stations": { + "http://data.entertainment.tv.theplatform.eu/entertainment/data/Station/259550248018": { + "id": "http://data.entertainment.tv.theplatform.eu/entertainment/data/Station/259550248018", + "title": "Das Erste", + "guid": "das_erste_hd", + "thumbnails": { + "stationLogoColored": { + "url": "https://example.com/das-erste-colored.png", + "width": 120, + "height": 48 + }, + "stationLogo": { + "url": "https://example.com/das-erste.png", + "width": 120, + "height": 48 + } + }, + "dt$serviceId": "das-erste", + "dt$quality": "HD" + } + }, + "dt$displayChannelNumber": 1, + "dt$isAdult": false, + "dt$isRadio": false + }, + { + "id": "http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/265809960058", + "title": "DabeiTV Radio - Main", + "stations": { + "http://data.entertainment.tv.theplatform.eu/entertainment/data/Station/265809960059": { + "id": "http://data.entertainment.tv.theplatform.eu/entertainment/data/Station/265809960059", + "title": "DabeiTV Radio", + "guid": "dabei_radio", + "dt$serviceId": "dabei-radio" + } + }, + "dt$displayChannelNumber": 865, + "dt$isAdult": false, + "dt$isRadio": true + } + ] + }, + "schedule": { + "startIndex": 1, + "itemsPerPage": 1, + "entryCount": 1, + "entries": [ + { + "id": "http://data.entertainment.tv.theplatform.eu/entertainment/data/ChannelSchedule/262270504164", + "guid": "sat1_hd-245991976396", + "channelNumber": 4, + "listings": [ + { + "id": "http://data.entertainment.tv.theplatform.eu/entertainment/data/Listing/775359528265", + "guid": "sat1_hd_03078f33", + "startTime": 1778458200000, + "endTime": 1778461620000, + "program": { + "id": "http://data.entertainment.tv.theplatform.eu/entertainment/data/Program/775354920143", + "title": "FBI: Special Crime Unit", + "description": "Eine gigantische Explosion in Brooklyn ruft das FBI auf den Plan. Hinweise deuten auf eine vorsätzliche Fremdeinwirkung hin.", + "isAdult": false, + "programType": "episode", + "ratings": [ + { + "scheme": "telekom-age-rating-2020", + "rating": "UNKNOWN", + "subRatings": [] + } + ], + "runtime": 3473, + "secondaryTitle": "Aufstand", + "seriesId": "http://data.entertainment.tv.theplatform.eu/entertainment/data/Program/guid/2709353023/telekom.de-110697", + "tags": [ + { + "scheme": "category", + "title": "200-Serie", + "titleLocalized": {} + }, + { + "scheme": "genre-primary", + "title": "Krimi", + "titleLocalized": {} + }, + { + "scheme": "genre-secondary", + "title": "Action", + "titleLocalized": {} + }, + { + "scheme": "genre-secondary", + "title": "Thriller", + "titleLocalized": {} + }, + { + "scheme": "genre-secondary", + "title": "Krimi", + "titleLocalized": {} + }, + { + "scheme": "programSubType", + "title": "linear_program", + "titleLocalized": {} + } + ], + "tvSeasonEpisodeNumber": null, + "tvSeasonNumber": null, + "year": 2025, + "dt$countries": "us", + "dt$originalIds": { + "cmlsProgramId": "03078f33", + "tva": "crid://telekom.de/03078f33" + } + }, + "stationId": "http://data.entertainment.tv.theplatform.eu/entertainment/data/Station/262269992390", + "dt$languages": ["deu"], + "dt$programInfo": "{'tvSeasonEpisodeNumber' : 7, 'tvSeasonNumber' : 8, 'seriesEpisodeNumber' : null}", + "dt$seriesTitle": "FBI: Special Crime Unit" + } + ] + } + ] + } +} diff --git a/sites/www.magenta.tv/readme.md b/sites/www.magenta.tv/readme.md new file mode 100644 index 000000000..5b38db06c --- /dev/null +++ b/sites/www.magenta.tv/readme.md @@ -0,0 +1,21 @@ +# www.magenta.tv + +https://www.magenta.tv/tv-guide + +### Download the guide + +```sh +npm run grab --- --sites=www.magenta.tv +``` + +### Update channel list + +```sh +npm run channels:parse --- --config=./sites/www.magenta.tv/www.magenta.tv.config.js --output=./sites/www.magenta.tv/www.magenta.tv.channels.xml +``` + +### Test + +```sh +npm test --- www.magenta.tv +``` diff --git a/sites/www.magenta.tv/www.magenta.tv.channels.xml b/sites/www.magenta.tv/www.magenta.tv.channels.xml new file mode 100644 index 000000000..192f7679e --- /dev/null +++ b/sites/www.magenta.tv/www.magenta.tv.channels.xml @@ -0,0 +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 + 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 new file mode 100644 index 000000000..f653b87c8 --- /dev/null +++ b/sites/www.magenta.tv/www.magenta.tv.config.js @@ -0,0 +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 + } +} + +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 new file mode 100644 index 000000000..fc415a733 --- /dev/null +++ b/sites/www.magenta.tv/www.magenta.tv.test.js @@ -0,0 +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' + }) + }) +}) + +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' + ) +})