Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sites/play.tv/__data__/content.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!DOCTYPE html><html><body><script>self.__next_f.push([1,"f:[[\"$\",\"$L39\",null,{\"brands\":[{\"id\":\"play\",\"label\":\"Play\",\"logo\":\"playBrand\",\"slug\":\"play\"},{\"id\":\"play_fictie\",\"label\":\"Play Fictie\",\"logo\":\"playfictie\",\"slug\":\"fictie\"},{\"id\":\"play_actie\",\"label\":\"Play Actie\",\"logo\":\"playactie\",\"slug\":\"actie\"},{\"id\":\"play_reality\",\"label\":\"Play Reality\",\"logo\":\"playreality\",\"slug\":\"reality\"},{\"id\":\"play_crime\",\"label\":\"Play Crime\",\"logo\":\"playcrime\",\"slug\":\"crime\"}],\"activeBrand\":\"play\"}]]"])</script><script>self.__next_f.push([1,"3b:[\"$\",\"div\",null,{\"className\":\"flex w-full flex-row justify-center\",\"id\":\"tvguide-list\",\"children\":[[\"$\",\"$L3c\",null,{\"brand\":\"play\",\"date\":\"2026-03-18\"}],[\"$\",\"ul\",null,{\"className\":\"bg-gray-background w-full pt-8\",\"children\":[[\"$\",\"$L3d\",\"10154143486360828-0\",{\"program\":{\"classification\":{\"age\":0,\"icons\":{\"summary\":[],\"full\":[]}},\"contentEpisode\":null,\"dateString\":\"2026-03-18\",\"duration\":3600,\"episodeNr\":null,\"episodeTitle\":null,\"genre\":null,\"isMovie\":false,\"latestVideo\":false,\"originalTitle\":null,\"program\":null,\"programConcept\":null,\"programTitle\":\"Geen uitzending - PLAY\",\"season\":null,\"timeString\":\"05:00\",\"timestamp\":1773806400,\"video\":null,\"wonId\":\"10154143486360828\",\"wonProgramId\":null},\"programVideoImage\":\"$undefined\"}],[\"$\",\"$L3d\",\"10135234661743527-4\",{\"program\":{\"classification\":{\"age\":0,\"icons\":{\"summary\":[],\"full\":[]}},\"contentEpisode\":null,\"dateString\":\"2026-03-18\",\"duration\":3000,\"episodeNr\":\"2\",\"episodeTitle\":\"Week 1: Gent\",\"genre\":\"Reality\",\"isMovie\":false,\"latestVideo\":true,\"originalTitle\":null,\"program\":{\"published\":true,\"shortFormType\":null,\"subtype\":\"default\",\"type\":\"program\",\"uuid\":\"5c30c8d1-a3e8-451a-b63b-695e8ba630d8\"},\"programConcept\":\"Vlaamse realityreeks over immo\",\"programTitle\":\"Huizenjagers\",\"season\":\"7\",\"timeString\":\"11:40\",\"timestamp\":1773830400,\"video\":{\"data\":{\"contextualTitle\":\"S7 - Afl. 2\",\"duration\":2295,\"images\":{\"default\":\"https://assets.play.tv/meta/huizenjagers-y07-e02-f0274581mp400122602still004-qgsi74-qh79ly-qh79ly.jpg\"},\"path\":\"/video/huizenjagers/huizenjagers-s7/huizenjagers-s7-aflevering-2\",\"title\":\"Huizenjagers - S7 - Aflevering 2\",\"type\":\"video\"},\"published\":true,\"shortFormType\":null,\"subtype\":\"longForm\",\"type\":\"video\",\"uuid\":\"8546e813-a657-45a8-8b98-c5e54827e201\"},\"wonId\":\"10146041503066527\",\"wonProgramId\":\"10135234661743527\"},\"programVideoImage\":\"https://images.play.tv/styles/0efe8c9a1a45511fe53ad47b02b82b32f480d1819874e6fb6d37c54cf9a2f5ea/meta/huizenjagers-y07-e02-f0274581mp400122602still004-qgsi74-qh79ly-qh79ly.jpg?style=W10=&sign=4e0abe87bc5d2922acf797fe1d11c9d64f80a3a1ec17bb9255c564e642605651\"}],[\"$\",\"$L3d\",\"10154143486360828-18\",{\"program\":{\"classification\":{\"age\":0,\"icons\":{\"summary\":[],\"full\":[]}},\"contentEpisode\":null,\"dateString\":\"2026-03-18\",\"duration\":6000,\"episodeNr\":null,\"episodeTitle\":null,\"genre\":null,\"isMovie\":false,\"latestVideo\":false,\"originalTitle\":null,\"program\":null,\"programConcept\":null,\"programTitle\":\"Geen uitzending - PLAY\",\"season\":null,\"timeString\":\"03:10\",\"timestamp\":1773886200,\"video\":null,\"wonId\":\"10154143486360828\",\"wonProgramId\":null},\"programVideoImage\":\"$undefined\"}],[\"$\",\"$L3d\",\"10154143486360828-19\",{\"program\":{\"classification\":{\"age\":0,\"icons\":{\"summary\":[],\"full\":[]}},\"contentEpisode\":null,\"dateString\":\"2026-03-18\",\"duration\":null,\"episodeNr\":null,\"episodeTitle\":null,\"genre\":null,\"isMovie\":false,\"latestVideo\":false,\"originalTitle\":null,\"program\":null,\"programConcept\":null,\"programTitle\":\"Geen uitzending - PLAY\",\"season\":null,\"timeString\":\"04:50\",\"timestamp\":1773892200,\"video\":null,\"wonId\":\"10154143486360828\",\"wonProgramId\":null},\"programVideoImage\":\"$undefined\"}]]}]}]"])</script></body></html>
8 changes: 8 additions & 0 deletions sites/play.tv/play.tv.channels.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<channels>
<channel site="play.tv" site_id="play" lang="nl" xmltv_id="Play.be">Play</channel>
<channel site="play.tv" site_id="actie" lang="nl" xmltv_id="PlayActie.be">Play Actie</channel>
<channel site="play.tv" site_id="crime" lang="nl" xmltv_id="PlayCrime.be">Play Crime</channel>
<channel site="play.tv" site_id="fictie" lang="nl" xmltv_id="PlayFictie.be">Play Fictie</channel>
<channel site="play.tv" site_id="reality" lang="nl" xmltv_id="PlayReality.be">Play Reality</channel>
</channels>
172 changes: 172 additions & 0 deletions sites/play.tv/play.tv.config.js
Original file line number Diff line number Diff line change
@@ -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)
}

70 changes: 70 additions & 0 deletions sites/play.tv/play.tv.test.js
Original file line number Diff line number Diff line change
@@ -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([])
})
21 changes: 21 additions & 0 deletions sites/play.tv/readme.md
Original file line number Diff line number Diff line change
@@ -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
```
Loading