diff --git a/sites/play.tv/__data__/content.html b/sites/play.tv/__data__/content.html new file mode 100644 index 0000000000..449c99faac --- /dev/null +++ b/sites/play.tv/__data__/content.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sites/play.tv/play.tv.channels.xml b/sites/play.tv/play.tv.channels.xml new file mode 100644 index 0000000000..025b604890 --- /dev/null +++ b/sites/play.tv/play.tv.channels.xml @@ -0,0 +1,8 @@ + + + Play + Play Actie + Play Crime + Play Fictie + Play Reality + diff --git a/sites/play.tv/play.tv.config.js b/sites/play.tv/play.tv.config.js new file mode 100644 index 0000000000..ddb9d13b67 --- /dev/null +++ b/sites/play.tv/play.tv.config.js @@ -0,0 +1,172 @@ +const axios = require('axios') +const dayjs = require('dayjs') +const utc = require('dayjs/plugin/utc') + +dayjs.extend(utc) + +const siteUrl = 'https://www.play.tv' +const nextFragmentPattern = /self\.__next_f\.push\(\[1,((?:"(?:\\.|[^"])*"))\]\)<\/script>/gs + +module.exports = { + site: 'play.tv', + days: 2, + request: { + headers: { + accept: 'text/html,application/xhtml+xml' + } + }, + url({ channel, date }) { + return `${siteUrl}/tv-gids/${channel.site_id}/${date.format('YYYY-MM-DD')}` + }, + parser({ content }) { + if (!content) return [] + + const items = parseProgramItems(content) + if (!items.length) return [] + + return items.map(item => { + const program = item.program + const path = program.video?.data?.path + const start = dayjs.unix(Number(program.timestamp)).utc() + const stop = start.add(Number(program.duration), 'second') + + return { + title: program.programTitle, + subTitle: program.episodeTitle || null, + description: program.contentEpisode || program.programConcept || null, + category: program.genre || null, + season: program.season ? Number(program.season) : null, + episode: program.episodeNr ? Number(program.episodeNr) : null, + image: item.programVideoImage || program.video?.data?.images?.default || null, + url: path ? `${siteUrl}${path}` : null, + start, + stop + } + }) + }, + async channels() { + const data = await axios + .get(`${siteUrl}/tv-gids`, module.exports.request) + .then(r => r.data) + .catch(() => '') + + return parseBrands(data).map(item => ({ + lang: 'nl', + site_id: item.slug, + name: item.label + })) + } +} + +function parseBrands(content) { + const fragments = extractNextFragments(content) + const stream = fragments.join('') + + const index = stream.indexOf('"brands":[') + if (index === -1) return [] + + const startIndex = stream.indexOf('[', index) + const arrayValue = extractBalancedValue(stream, startIndex, '[', ']') + const brands = parseJsonValue(arrayValue) + + return Array.isArray(brands) ? brands : [] +} + +function parseProgramItems(content) { + const fragments = extractNextFragments(content) + const stream = fragments.join('') + const programs = [] + + let index = stream.indexOf('{"program":') + + while (index !== -1) { + const objectValue = extractBalancedValue(stream, index, '{', '}') + + if (!objectValue) { + index = stream.indexOf('{"program":', index + 1) + continue + } + + const programItem = parseJsonValue(objectValue) + + if (hasDuration(programItem) && programItem.program.programTitle) { + programs.push(programItem) + } + + index = stream.indexOf('{"program":', index + objectValue.length) + } + + return programs.sort((left, right) => Number(left.program.timestamp) - Number(right.program.timestamp)) +} + +function extractNextFragments(content) { + return Array.from(content.matchAll(nextFragmentPattern)) + .map(([, encodedFragment]) => { + try { + return JSON.parse(encodedFragment) + } catch { + return null + } + }) + .filter(Boolean) +} + +function extractBalancedValue(content, startIndex, openChar, closeChar) { + let depth = 0 + let escaped = false + let insideString = false + + for (let index = startIndex; index < content.length; index++) { + const character = content[index] + + if (escaped) { + escaped = false + continue + } + + if (character === '\\') { + escaped = true + continue + } + + if (character === '"') { + insideString = !insideString + continue + } + + if (insideString) continue + + if (character === openChar) depth++ + if (character === closeChar) depth-- + + if (depth === 0) return content.slice(startIndex, index + 1) + } + + return '' +} + +function parseJsonValue(value) { + if (!value) return null + + try { + return JSON.parse(value.replace(/"\$undefined"/g, 'null')) + } catch { + return null + } +} + +function hasDuration(programItem) { + const title = programItem?.program?.programTitle || '' + + return Boolean( + programItem?.program?.timestamp && + title && + !isPlaceholderTitle(title) && + Number(programItem.program.duration) > 0 + ) +} + +function isPlaceholderTitle(title) { + return /^geen uitzending\b/i.test(title) +} + diff --git a/sites/play.tv/play.tv.test.js b/sites/play.tv/play.tv.test.js new file mode 100644 index 0000000000..16173905dd --- /dev/null +++ b/sites/play.tv/play.tv.test.js @@ -0,0 +1,70 @@ +jest.mock('axios', () => ({ + get: jest.fn() +})) + +const axios = require('axios') +const { channels, parser, url } = require('./play.tv.config.js') +const fs = require('fs') +const path = require('path') +const dayjs = require('dayjs') +const utc = require('dayjs/plugin/utc') +const customParseFormat = require('dayjs/plugin/customParseFormat') + +dayjs.extend(customParseFormat) +dayjs.extend(utc) + +const date = dayjs.utc('2026-03-18', 'YYYY-MM-DD').startOf('d') +const channel = { site_id: 'play', xmltv_id: '' } +const content = fs.readFileSync(path.resolve(__dirname, '__data__/content.html'), 'utf8') + +beforeEach(() => { + axios.get.mockReset() +}) + +it('can generate valid url', () => { + expect(url({ channel, date })).toBe('https://www.play.tv/tv-gids/play/2026-03-18') +}) + +it('can parse response', () => { + const results = parser({ content, channel, date }).map(program => { + program.start = program.start.toJSON() + program.stop = program.stop.toJSON() + return program + }) + + expect(results).toHaveLength(1) + expect(results.find(program => program.title === 'Geen uitzending - PLAY')).toBeUndefined() + expect(results[0]).toMatchObject({ + title: 'Huizenjagers', + subTitle: 'Week 1: Gent', + description: 'Vlaamse realityreeks over immo', + category: 'Reality', + season: 7, + episode: 2, + image: + 'https://images.play.tv/styles/0efe8c9a1a45511fe53ad47b02b82b32f480d1819874e6fb6d37c54cf9a2f5ea/meta/huizenjagers-y07-e02-f0274581mp400122602still004-qgsi74-qh79ly-qh79ly.jpg?style=W10=&sign=4e0abe87bc5d2922acf797fe1d11c9d64f80a3a1ec17bb9255c564e642605651', + url: 'https://www.play.tv/video/huizenjagers/huizenjagers-s7/huizenjagers-s7-aflevering-2', + start: '2026-03-18T10:40:00.000Z', + stop: '2026-03-18T11:30:00.000Z' + }) +}) + +it('can parse channel list', async () => { + axios.get.mockResolvedValue({ data: content }) + + const results = await channels() + + expect(results).toMatchObject([ + { lang: 'nl', name: 'Play', site_id: 'play' }, + { lang: 'nl', name: 'Play Fictie', site_id: 'fictie' }, + { lang: 'nl', name: 'Play Actie', site_id: 'actie' }, + { lang: 'nl', name: 'Play Reality', site_id: 'reality' }, + { lang: 'nl', name: 'Play Crime', site_id: 'crime' } + ]) +}) + +it('can handle empty guide', () => { + const results = parser({ content: '', channel, date }) + + expect(results).toMatchObject([]) +}) \ No newline at end of file diff --git a/sites/play.tv/readme.md b/sites/play.tv/readme.md new file mode 100644 index 0000000000..3b50d570bf --- /dev/null +++ b/sites/play.tv/readme.md @@ -0,0 +1,21 @@ +# play.tv + +https://www.play.tv + +### Download the guide + +```sh +npm run grab --- --site=play.tv +``` + +### Update channel list + +```sh +npm run channels:parse --- --config=./sites/play.tv/play.tv.config.js --output=./sites/play.tv/play.tv.channels.xml +``` + +### Test + +```sh +npm test --- play.tv +``` \ No newline at end of file