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