This commit is contained in:
Dan Ferguson 2020-04-07 17:03:39 -04:00
parent cfb86224a3
commit d28032fab4
24 changed files with 1436 additions and 852 deletions

View File

@ -6,18 +6,26 @@ Create Live TV/DVR channels from playlists in Plex.
### How it works
1. pseudotv-plex will scan Plex for playlists. Playlists with a **summary** starting with **pseudotv** will be fetched.
2. XMLTV and M3U files are generated from fetched playlists
3. Add the pseudotv (spoofed HDHomeRun) tuner into Plex, use the XMLTV file for guide information.
4. When tuning to a channel, a VLC session will be **spawned on demand**, hosting the channel's video stream.
5. Whenever a playlist change is detected, the M3U and XMLTV files will be rewritten
1. pseudotv-plex will scan Plex for playlists. Playlists with a summary starting with **pseudotv** will be fetched.
2. XMLTV and M3U files are generated from playlists, using metadata pulled from Plex.
3. Add the PseudoTV (spoofed HDHomeRun) tuner into Plex, use the XMLTV file as your EPG provider.
4. Watch your psudeo live tv channels
### Features
- Supports any video playlist in Plex, including Smart Playlists
- VLC sessions are spawned on demand. There will only ever be one VLC session per channel, no matter the number of viewers.
- VLC will **Direct Stream** if media is tagged **"optimizedForStreaming"** by Plex, otherwise VLC will transcode to h264/aac.
- EPG/Channels update automatically
- Plex transcoding (psuedotv-plex spoofs a Chrome Web Player, in order to receive a h264/aac stream from Plex)
- Live FFMPEG or VLC mpegts transmuxing
- Prebuffering (FFMPEG only) - transcodes entire video as fast as possible (not live stream)
- Auto update Plex DVR channel mappings and EPG.
- Web UI for manually triggering EPG updates
**So far only tested in Windows. Should work cross platform. Docker container support coming soon.**
**Critical Issues: Continuous playback is pretty much broken. I think the only way to get around that would be to transcode videos to a fixed framerate/bitrate. I really wish Plex documented their full API, there might be some parameters we can send to get such a stream..**
## Prerequisites
Install [NodeJS](https://nodejs.org/), and either [VLC](https://www.videolan.org/vlc/) or [FFMPEG](https://www.ffmpeg.org/)
## Install
```
@ -25,9 +33,10 @@ npm install
```
## Configure
### You must provide your Plex server details and the location of VLC
Edit the **config.yml** configuration file
You must provide your Plex server details and the location of VLC or FFMPEG
### Edit the **`config.yml`** configuration file
## Start
```
@ -36,30 +45,35 @@ npm start
# Plex Playlist Setup
To assign a playlist as a channel, edit the summary if the playlist and write **pseudotv**.
To assign a playlist as a channel, edit the summary of the playlist in Plex and write **pseudotv** at the beginning.
Channel number and icon url are **optional** parameters.
**optional parameters:** *channelNumber*, *iconURL* and/or *shuffle*. In any order..
Default channel number is the random Plex playlist ID
![Playlist Setup](docs/playlist.png)
## Plex Playlist Example
### Title
```
My Channel Name
```
### Summary
```
pseudotv 100 shuffle http://some.url/channel-icon.png
```
# Plex DVR Setup
Add the pseudotv-plex tuner to Plex. Use the **"Don't see your HDHomerun device? Enter its network address manually"** option if it doesn't show up automatically.
Add the PseudoTV tuner to Plex. Use the **"Don't see your HDHomerun device? Enter its network address manually"** option if it doesn't show up automatically.
Click the **continue** button after clicking **connect**
Use the generated XMLTV file as your EPG provider.
![DVR Setup - Step 1](docs/dvr1.png)
You wont be able to add the tuner to Plex until at least one channel has been generated.
Channels imported from Plex Playlists. **NOTE: If a new channel/playlist is added, you have to remove and re-setup the tuner in plex.**
# Plex Transcoding
When a channel is requested, pseudotv-plex will determine the current playing program and request a transcoded stream from Plex. When pseudotv-plex recieves the h264/acc stream,it is remuxed (using vlc or ffmpeg) into a mpegts container to be utilized by Plex DVR.
![DVR Setup - Step 2](docs/dvr2.png)
![DVR Guide](docs/transcode.png)
**Use the XMLTV option and select the pseudotv-plex generated xmltv.xml file**
# PseudoTV Web UI
![DVR Setup - Step 3](docs/dvr3.png)
Manually trigger EPG updates and view active channels using the Web UI.
Channels should automatically be matched. **Click continue**
![DVR Setup - Step 4](docs/dvr4.png)
![DVR Guide](docs/pseudotv.png)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

BIN
docs/pseudotv.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
docs/transcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

View File

@ -1,47 +1,37 @@
const express = require('express')
const fs = require('fs')
var path = require("path")
const config = require('config-yml')
var config = require('config-yml')
const hdhr = require('./src/hdhr')
const vlc = require('./src/vlc')
const plex = require('./src/plex')
const xmltv = require('./src/xmltv')
const m3u = require('./src/m3u')
const plex = require('./src/plex')
const ffmpeg = require('./src/ffmpeg')
const vlc = require('./src/vlc')
const hdhr = require('./src/hdhr')()
const pseudotv = require('./src/pseudotv')
// Plex does not update the playlists updatedAt property when the summary or title changes
var lastPlaylistUpdate = 0 // to watch for playlist updates
var channelsInfo = "" // to watch for playlist updates
plex(config.PLEX_OPTIONS, (result) => {
if (result.err)
return console.error("Failed to create plex client.", result.err)
var client = result.client
var refreshDate = new Date() // when the EPG will be updated
refreshDate.setHours(refreshDate.getHours() + config.EPG_REFRESH)
console.log("Plex authentication successful")
plex.PlexChannelScan((channels, lastUpdate, info) => {
console.log(`Generating EPG data(XMLTV) and channel playlists (M3U) from Plex. Channels: ${channels.length}`)
lastPlaylistUpdate = lastUpdate
channelsInfo = info
m3u.WriteM3U(channels, () => { console.log(`M3U File Location: ${path.resolve(config.M3U_OUTPUT)}`) })
xmltv.WriteXMLTV(channels, () => { console.log(`XMLTV File Location: ${path.resolve(config.XMLTV_OUTPUT)}`) })
})
setInterval(() => {
plex.PlexChannelScan((channels, lastUpdate, info) => {
var now = new Date()
// Update EPG whenever a psuedotv playlist is updated/added/removed, or at EPG_REFRESH interval
if (lastUpdate > lastPlaylistUpdate || channelsInfo !== info || now > refreshDate ) {
console.log(`Updating EPG data(XMLTV) and channel playlists (M3U) from Plex. Channels: ${channels.length}`)
m3u.WriteM3U(channels)
xmltv.UpdateXMLTV(channels)
lastPlaylistUpdate = lastUpdate
channelsInfo = info
refreshDate.setHours(refreshDate.getHours() + config.EPG_REFRESH)
}})
}, config.PLEX_PLAYLIST_FETCH_TIMER * 1000)
var app = express()
app.use(hdhr.router())
app.use(vlc.router())
app.listen(config.PORT, () => {
hdhr.start()
console.log(`Hosting VLC / HDHomeRun server at: http://${config.HOST}:${config.PORT}`)
var app = express()
if (config.MUXER.toLowerCase() === 'ffmpeg')
app.use(ffmpeg(client))
else if (config.MUXER.toLowerCase() === 'vlc')
app.use(vlc(client))
else
return console.error("Invalid MUXER specified in config.yml")
if (config.HDHOMERUN_OPTIONS.ENABLED)
app.use(hdhr.router)
app.use(pseudotv(client, xmltv, m3u))
app.listen(config.PORT, () => {
console.log(`pseudotv-plex: http://${config.HOST}:${config.PORT}`)
if (config.HDHOMERUN_OPTIONS.ENABLED && config.HDHOMERUN_OPTIONS.AUTODISCOVERY)
hdhr.ssdp.start()
})
})

292
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "psuedotv-plex",
"name": "pseudotv-plex",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
@ -83,14 +83,6 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"requires": {
"safe-buffer": "5.1.2"
}
},
"bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@ -99,6 +91,14 @@
"tweetnacl": "^0.14.3"
}
},
"bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"requires": {
"file-uri-to-path": "1.0.0"
}
},
"bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@ -119,6 +119,13 @@
"qs": "6.7.0",
"raw-body": "2.4.0",
"type-is": "~1.6.17"
},
"dependencies": {
"qs": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
}
}
},
"brace-expansion": {
@ -191,6 +198,13 @@
"integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
"requires": {
"safe-buffer": "5.1.2"
},
"dependencies": {
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
"content-type": {
@ -234,6 +248,11 @@
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
},
"decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -331,6 +350,18 @@
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"dependencies": {
"qs": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
},
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
"extend": {
@ -353,6 +384,11 @@
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
},
"file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
},
"finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
@ -451,6 +487,11 @@
"har-schema": "^2.0.0"
}
},
"hoek": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.1.tgz",
"integrity": "sha512-QLg82fGkfnJ/4iy1xZ81/9SIJiq1NGFUMGs6ParyjBZr6jW2Ufj/snDqTHixNlHdPNwN2RLVD0Pi3igeK9+JfA=="
},
"hosted-git-info": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz",
@ -466,6 +507,13 @@
"setprototypeof": "1.1.1",
"statuses": ">= 1.5.0 < 2",
"toidentifier": "1.0.0"
},
"dependencies": {
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
}
}
},
"http-signature": {
@ -496,9 +544,9 @@
}
},
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"interpret": {
"version": "1.2.0",
@ -543,11 +591,36 @@
"resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz",
"integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI="
},
"isemail": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/isemail/-/isemail-3.2.0.tgz",
"integrity": "sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==",
"requires": {
"punycode": "2.x.x"
}
},
"isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
},
"joi": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/joi/-/joi-13.7.0.tgz",
"integrity": "sha512-xuY5VkHfeOYK3Hdi91ulocfuFopwgbSORmIwzcwHKESQhC7w1kD5jaVSPnqDxS2I8t3RZ9omCKAxNwXN5zG1/Q==",
"requires": {
"hoek": "5.x.x",
"isemail": "3.x.x",
"topo": "3.x.x"
},
"dependencies": {
"hoek": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/hoek/-/hoek-5.0.4.tgz",
"integrity": "sha512-Alr4ZQgoMlnere5FZJsIyfIjORBqZll5POhDsF4q64dPuJR6rNxXdDxtHSQq8OXRurhmx+PWYEE8bXRROY8h0w=="
}
}
},
"js-yaml": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz",
@ -664,35 +737,30 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
},
"morgan": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
"integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==",
"requires": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-finished": "~2.3.0",
"on-headers": "~1.0.2"
},
"dependencies": {
"depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
}
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"nan": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
"integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg=="
},
"negotiator": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
},
"node-expat": {
"version": "2.3.18",
"resolved": "https://registry.npmjs.org/node-expat/-/node-expat-2.3.18.tgz",
"integrity": "sha512-9dIrDxXePa9HSn+hhlAg1wXkvqOjxefEbMclGxk2cEnq/Y3U7Qo5HNNqeo3fQ4bVmLhcdt3YN1TZy7WMZy4MHw==",
"requires": {
"bindings": "^1.5.0",
"nan": "^2.13.2"
}
},
"node-ssdp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/node-ssdp/-/node-ssdp-4.0.0.tgz",
@ -749,11 +817,6 @@
"ee-first": "1.1.1"
}
},
"on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA=="
},
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -839,45 +902,6 @@
"pinkie": "^2.0.0"
}
},
"plex-api": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/plex-api/-/plex-api-5.3.1.tgz",
"integrity": "sha512-WQVNOEqTCRx0/3oW5Orc+0OLAyQiDisCQ36mSVQHZOuuwXVj+l6WY9EksJzDXclp7Az3U8I/BNFDnopP1NxAFw==",
"requires": {
"plex-api-credentials": "3.0.1",
"plex-api-headers": "1.1.0",
"request": "^2.87.0",
"uuid": "2.0.2",
"xml2js": "0.4.16"
}
},
"plex-api-credentials": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/plex-api-credentials/-/plex-api-credentials-3.0.1.tgz",
"integrity": "sha512-E0PdSVSqE5rmdEFNsIvFPDJQZPdBX7UR4sgkm9HF4V8VNbX0N4elASnMuoste8i9eTh4hCIqt761NQfzl45XnQ==",
"requires": {
"bluebird": "^3.3.5",
"plex-api-headers": "1.1.0",
"request-promise": "4.2.4",
"xml2js": "0.4.19"
},
"dependencies": {
"xml2js": {
"version": "0.4.19",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz",
"integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~9.0.1"
}
}
}
},
"plex-api-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/plex-api-headers/-/plex-api-headers-1.1.0.tgz",
"integrity": "sha1-TONkcV2WSMzPLAZFgyee+HwS+/I="
},
"proxy-addr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
@ -888,9 +912,9 @@
}
},
"psl": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.7.0.tgz",
"integrity": "sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ=="
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
},
"punycode": {
"version": "2.1.1",
@ -898,9 +922,19 @@
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"qs": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
},
"query-string": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-6.11.1.tgz",
"integrity": "sha512-1ZvJOUl8ifkkBxu2ByVM/8GijMIPx+cef7u3yroO3Ogm4DOdZcF5dcrWTIlSHe3Pg/mtlt6/eFjObDfJureZZA==",
"requires": {
"decode-uri-component": "^0.2.0",
"split-on-first": "^1.0.0",
"strict-uri-encode": "^2.0.0"
}
},
"range-parser": {
"version": "1.2.1",
@ -972,11 +1006,6 @@
"uuid": "^3.3.2"
},
"dependencies": {
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
"integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
},
"uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
@ -984,25 +1013,6 @@
}
}
},
"request-promise": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.4.tgz",
"integrity": "sha512-8wgMrvE546PzbR5WbYxUQogUnUDfM0S7QIFZMID+J73vdFARkFy+HElj4T+MWYhpXwlLp0EQ8Zoj8xUA0he4Vg==",
"requires": {
"bluebird": "^3.5.0",
"request-promise-core": "1.1.2",
"stealthy-require": "^1.1.1",
"tough-cookie": "^2.3.3"
}
},
"request-promise-core": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz",
"integrity": "sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag==",
"requires": {
"lodash": "^4.17.11"
}
},
"require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -1022,20 +1032,15 @@
}
},
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
@ -1127,6 +1132,11 @@
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz",
"integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q=="
},
"split-on-first": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@ -1153,10 +1163,10 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
},
"stealthy-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz",
"integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks="
"strict-uri-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY="
},
"string-width": {
"version": "1.0.2",
@ -1189,6 +1199,21 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
},
"topo": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/topo/-/topo-3.0.3.tgz",
"integrity": "sha512-IgpPtvD4kjrJ7CRA3ov2FhWQADwv+Tdqbsf1ZnPUSAtCJ9e1Z44MmoSGDXGk4IppoZA7jd/QRkNddlLJWlUZsQ==",
"requires": {
"hoek": "6.x.x"
},
"dependencies": {
"hoek": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/hoek/-/hoek-6.1.3.tgz",
"integrity": "sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ=="
}
}
},
"tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
@ -1238,11 +1263,6 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
},
"uuid": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.2.tgz",
"integrity": "sha1-SL1WmPBnfjx5AaHEbvFbFkN5RyY="
},
"validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
@ -1313,30 +1333,16 @@
"resolved": "https://registry.npmjs.org/xml-writer/-/xml-writer-1.7.0.tgz",
"integrity": "sha1-t28dWRwWomNOvbcDx729D9aBkGU="
},
"xml2js": {
"version": "0.4.16",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.16.tgz",
"integrity": "sha1-+C/M0vlUDX4Km12sFj50cRlcnbM=",
"xml2json": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/xml2json/-/xml2json-0.12.0.tgz",
"integrity": "sha512-EPJHRWJnJUYbJlzR4pBhZODwWdi2IaYGtDdteJi0JpZ4OD31IplWALuit8r73dJuM4iHZdDVKY1tLqY2UICejg==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "^4.1.0"
},
"dependencies": {
"xmlbuilder": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-4.2.1.tgz",
"integrity": "sha1-qlijBBoGb5DqoWwvU4n/GfP0YaU=",
"requires": {
"lodash": "^4.0.0"
}
}
"hoek": "^4.2.1",
"joi": "^13.1.2",
"node-expat": "^2.3.18"
}
},
"xmlbuilder": {
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz",
"integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0="
},
"y18n": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz",

View File

@ -1,7 +1,7 @@
{
"name": "psuedotv-plex",
"name": "pseudotv-plex",
"version": "1.0.0",
"description": "",
"description": "Create Live TV/DVR channels from playlists in Plex.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
@ -13,11 +13,11 @@
"dependencies": {
"config-yml": "^0.10.3",
"express": "^4.17.1",
"morgan": "^1.10.0",
"node-ssdp": "^4.0.0",
"plex-api": "^5.3.1",
"query-string": "^6.11.1",
"request": "^2.88.2",
"xml-reader": "^2.4.3",
"xml-writer": "^1.7.0"
"xml-writer": "^1.7.0",
"xml2json": "^0.12.0"
}
}

134
src/ffmpeg.js Normal file
View File

@ -0,0 +1,134 @@
const Router = require('express').Router
const spawn = require('child_process').spawn
const config = require('config-yml')
const xmltv = require('./xmltv')
module.exports = ffmpegRouter
function ffmpegRouter(client) {
var router = Router()
var inUse = false
router.get('/video', (req, res) => {
if (inUse)
return res.status(409).send("Error: Another user is currently viewing a stream. One one active stream is allowed.")
inUse = true
var channel = req.query.channel
if (!channel) {
inUse = false
res.status(400).send("Error: No channel queried")
return
}
channel = channel.split('?')[0]
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-disposition': 'attachment; filename=video.ts'
})
startStreaming(channel, res)
})
return router
function startStreaming(channel, res) {
var programs = xmltv.readXMLPrograms()
// Find the current program for channel, calculate video start position
var startPos = -1
var programIndex = -1
var channelExists = false
for (var i = 0; i < programs.length; i++) {
var date = new Date()
if (programs[i].channel == channel) {
channelExists = true
if (programs[i].start <= date && programs[i].stop >= date) {
startPos = date.getTime() - programs[i].start.getTime()
programIndex = i
break
}
}
}
// End session if any errors.
if (!channelExists) {
inUse = false
res.status(403).send(`Error: Channel doesn't exist. Channel: ${channel}`)
return
}
if (programIndex === -1) {
inUse = false
res.status(403).send(`Error: No scheduled programming available. Channel: ${channel}`)
return
}
if (startPos === -1) {
inUse = false
res.status(403).send(`Error: How the fuck did you get here?. Channel: ${channel}`)
return
}
// Query plex for current program
client.Get(programs[programIndex].key, (result) => {
if (result.err) {
inUse = false
res.status(403).send(`Error: Failed to fetch program info from Plex`)
return
}
var fetchedItem = result.result.MediaContainer.Metadata[0]
// Transcode it
client.Transcode(fetchedItem, startPos, (result) => {
if (result.err) {
inUse = false
res.status(403).send(`Error: Failed to add program to playQueue`)
return
}
// Update server timeline every 10 seconds
var stream = result.result
var msElapsed = startPos
var timelineInterval = setInterval(() => {
stream.update(msElapsed)
msElapsed += 10000
}, 10000)
// Start transmuxing, pipe ffmpeg's output to stdout
var args = [
'-re', // Live Stream
'-ss', startPos / 1000, // Start Time (eg: 00:01:23.000 or 83 (seconds))
'-i', stream.url, // Source
'-f', 'mpegts', // Output Format
'-c', 'copy', // Copy Video/Audio Streams
'pipe:1' // Output on stdout
]
if (config.FFMPEG_OPTIONS.PREBUFFER)
args.shift()
var ffmpeg = spawn(config.FFMPEG_OPTIONS.PATH, args)
// Write the chunks to response
ffmpeg.stdout.on('data', (chunk) => {
res.write(chunk)
})
// When the http session ends: kill ffmpeg
var httpEnd = function () {
ffmpeg.kill()
inUse = false
}
res.on('close', httpEnd)
// When ffmpeg closes: kill the timelineInterval, recurse to next program.. Since MPEGTS files can be concatenated together, this should work.....
ffmpeg.on('close', (code) => {
clearInterval(timelineInterval)
// if ffmpeg closed because we hit the end of the video..
if (code === 0) { // stream the next episode
var end = programs[programIndex].stop
var now = new Date()
var timeUntilDone = end.valueOf() - now.valueOf()
timeUntilDone = timeUntilDone > 0 ? timeUntilDone : 0
setTimeout(() => {
stream.stop()
res.removeListener('close', httpEnd)
startStreaming(channel, res)
}, timeUntilDone) // wait until end of video before we start sending the stream
} else if (inUse && !res.headersSent) {
stream.stop()
res.status(400).send(`Error: FFMPEG closed unexpectedly`)
inUse = false
} else {
stream.stop()
}
})
})
})
}
}

View File

@ -1,400 +0,0 @@
const Router = require('express').Router
const SSDP = require('node-ssdp').Server
const fs = require('fs')
const config = require('config-yml')
const m3u = require('./m3u')
var device = {
FriendlyName: "PseudoTV-Plex",
Manufacturer: "Silicondust",
ManufacturerURL: "https://github.com/DEFENDORe/pseudotv-plex",
ModelNumber: "HDTC-2US",
FirmwareName: "hdhomeruntc_atsc",
TunerCount: config.HDHR_OPTIONS.tuners,
FirmwareVersion: "20170930",
DeviceID: config.HDHR_OPTIONS.uuid,
DeviceAuth: "test1234",
BaseURL: `http://${config.HOST}:${config.PORT}`,
LineupURL: `http://${config.HOST}:${config.PORT}/lineup.json`
}
const server = new SSDP({
location: {
port: config.PORT,
path: '/device.xml'
},
udn: `uuid:${device.DeviceID}`,
allowWildcards: true,
ssdpSig: 'PPTV/3.0 UPnP/1.0'
})
function startHDHR() {
server.addUSN('upnp:rootdevice')
server.addUSN('urn:schemas-upnp-org:device:MediaServer:1')
server.addUSN('urn:schemas-upnp-org:service:ContentDirectory:1')
server.addUSN('urn:schemas-upnp-org:service:ConnectionManager:1')
server.start()
}
function HDHRRouter() {
const router = Router()
router.get('/device.xml', (req, res) => {
res.header("Content-Type", "application/xml")
var data = `<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:dlna="urn:schemas-dlna-org:device-1-0" xmlns:pnpx="http://schemas.microsoft.com/windows/pnpx/2005/11" xmlns:df="http://schemas.microsoft.com/windows/2008/09/devicefoundation">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<URLBase>${device.BaseURL}</URLBase>
<device>
<dlna:X_DLNADOC>DMS-1.50</dlna:X_DLNADOC>
<pnpx:X_hardwareId>VEN_0115&amp;DEV_1040&amp;SUBSYS_0001&amp;REV_0004 VEN_0115&amp;DEV_1040&amp;SUBSYS_0001 VEN_0115&amp;DEV_1040</pnpx:X_hardwareId>
<pnpx:X_deviceCategory>MediaDevices</pnpx:X_deviceCategory>
<df:X_deviceCategory>Multimedia</df:X_deviceCategory>
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<friendlyName>${device.FriendlyName}</friendlyName>
<presentationURL>/</presentationURL>
<manufacturer>${device.Manufacturer}</manufacturer>
<manufacturerURL>${device.ManufacturerURL}</manufacturerURL>
<modelDescription>${device.FriendlyName}</modelDescription>
<modelName>${device.FriendlyName}</modelName>
<modelNumber>${device.ModelNumber}</modelNumber>
<modelURL>${device.ManufacturerURL}</modelURL>
<serialNumber></serialNumber>
<UDN>uuid:${device.DeviceID}</UDN>
</device>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
<SCPDURL>/ConnectionManager.xml</SCPDURL>
<controlURL>${device.BaseURL}/ConnectionManager.xml</controlURL>
<eventSubURL>${device.BaseURL}/ConnectionManager.xml</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
<SCPDURL>/ContentDirectory.xml</SCPDURL>
<controlURL>${device.BaseURL}/ContentDirectory.xml</controlURL>
<eventSubURL>${device.BaseURL}/ContentDirectory.xml</eventSubURL>
</service>
</serviceList>
</root>`
res.send(data)
})
router.get('/ConnectionManager.xml', (req, res) => {
res.header("Content-Type", "application/xml")
var data = `
<?xml version="1.0" encoding="utf-8" ?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>GetProtocolInfo</name>
<argumentList>
<argument>
<name>Source</name>
<direction>out</direction>
<relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>Sink</name>
<direction>out</direction>
<relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetCurrentConnectionIDs</name>
<argumentList>
<argument>
<name>ConnectionIDs</name>
<direction>out</direction>
<relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetCurrentConnectionInfo</name>
<argumentList>
<argument>
<name>ConnectionID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>RcsID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
</argument>
<argument>
<name>AVTransportID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
</argument>
<argument>
<name>ProtocolInfo</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionManager</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>Direction</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
</argument>
<argument>
<name>Status</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="yes">
<name>SourceProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>SinkProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>CurrentConnectionIDs</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionStatus</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>OK</allowedValue>
<allowedValue>ContentFormatMismatch</allowedValue>
<allowedValue>InsufficientBandwidth</allowedValue>
<allowedValue>UnreliableChannel</allowedValue>
<allowedValue>Unknown</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionManager</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Direction</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Input</allowedValue>
<allowedValue>Output</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionID</name>
<dataType>i4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_AVTransportID</name>
<dataType>i4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RcsID</name>
<dataType>i4</dataType>
</stateVariable>
</serviceStateTable>
</scpd>`;
res.send(data)
})
router.get('/ContentDirectory.xml', (req, res) => {
res.header("Content-Type", "application/xml")
var data = `
<?xml version="1.0" encoding="utf-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>Browse</name>
<argumentList>
<argument>
<name>ObjectID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>BrowseFlag</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable>
</argument>
<argument>
<name>Filter</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
</argument>
<argument>
<name>StartingIndex</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
</argument>
<argument>
<name>RequestedCount</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>SortCriteria</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
</argument>
<argument>
<name>Result</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
<argument>
<name>NumberReturned</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>TotalMatches</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>UpdateID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetSearchCapabilities</name>
<argumentList>
<argument>
<name>SearchCaps</name>
<direction>out</direction>
<relatedStateVariable>SearchCapabilities</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetSortCapabilities</name>
<argumentList>
<argument>
<name>SortCaps</name>
<direction>out</direction>
<relatedStateVariable>SortCapabilities</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetSystemUpdateID</name>
<argumentList>
<argument>
<name>Id</name>
<direction>out</direction>
<relatedStateVariable>SystemUpdateID</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_SortCriteria</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_UpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Filter</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Result</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Index</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ObjectID</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>SortCapabilities</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>SearchCapabilities</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Count</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_BrowseFlag</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>BrowseMetadata</allowedValue>
<allowedValue>BrowseDirectChildren</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="yes">
<name>SystemUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
</serviceStateTable>
</scpd>`
res.send(data)
})
router.get('/discover.json', (req, res) => {
res.header("Content-Type", "application/json")
res.send(JSON.stringify(device))
})
router.get('/lineup_status.json', (req, res) => {
res.header("Content-Type", "application/json")
var data = {
ScanInProgress: 0,
ScanPossible: 1,
Source: "Cable",
SourceList: ["Cable"],
}
res.send(JSON.stringify(data))
})
router.get('/lineup.json', (req, res) => {
res.header("Content-Type", "application/json")
var data = m3u.ReadChannels()
res.send(JSON.stringify(data))
})
return router
}
module.exports = { router: HDHRRouter, start: startHDHR }

62
src/hdhr/device.js Normal file
View File

@ -0,0 +1,62 @@
var config = require('config-yml')
function device() {
var device = {
friendlyName: "PseudoTV",
manufacturer: "Silicondust",
manufacturerURL: "https://github.com/DEFENDORe/pseudotv-plex",
modelNumber: "HDTC-2US",
firmwareName: "hdhomeruntc_atsc",
tunerCount: 1,
firmwareVersion: "20170930",
deviceID: 'PseudoTV',
deviceAuth: "",
baseURL: `http://${config.HOST}:${config.PORT}`,
lineupURL: `http://${config.HOST}:${config.PORT}/lineup.json`
}
device.getXml = () => {
return `<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:dlna="urn:schemas-dlna-org:device-1-0" xmlns:pnpx="http://schemas.microsoft.com/windows/pnpx/2005/11" xmlns:df="http://schemas.microsoft.com/windows/2008/09/devicefoundation">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<URLBase>${device.baseURL}</URLBase>
<device>
<dlna:X_DLNADOC>DMS-1.50</dlna:X_DLNADOC>
<pnpx:X_hardwareId>VEN_0115&amp;DEV_1040&amp;SUBSYS_0001&amp;REV_0004 VEN_0115&amp;DEV_1040&amp;SUBSYS_0001 VEN_0115&amp;DEV_1040</pnpx:X_hardwareId>
<pnpx:X_deviceCategory>MediaDevices</pnpx:X_deviceCategory>
<df:X_deviceCategory>Multimedia</df:X_deviceCategory>
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<friendlyName>${device.friendlyName}</friendlyName>
<presentationURL>/</presentationURL>
<manufacturer>${device.manufacturer}</manufacturer>
<manufacturerURL>${device.manufacturerURL}</manufacturerURL>
<modelDescription>${device.friendlyName}</modelDescription>
<modelName>${device.friendlyName}</modelName>
<modelNumber>${device.modelNumber}</modelNumber>
<modelURL>${device.manufacturerURL}</modelURL>
<serialNumber></serialNumber>
<UDN>uuid:${device.deviceID}</UDN>
</device>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
<SCPDURL>/ConnectionManager.xml</SCPDURL>
<controlURL>${device.baseURL}/ConnectionManager.xml</controlURL>
<eventSubURL>${device.baseURL}/ConnectionManager.xml</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
<SCPDURL>/ContentDirectory.xml</SCPDURL>
<controlURL>${device.baseURL}/ContentDirectory.xml</controlURL>
<eventSubURL>${device.baseURL}/ContentDirectory.xml</eventSubURL>
</service>
</serviceList>
</root>`
}
return device
}
module.exports = device

57
src/hdhr/index.js Normal file
View File

@ -0,0 +1,57 @@
const express = require('express')
const SSDP = require('node-ssdp').Server
const config = require('config-yml')
const m3u = require('../m3u')
function hdhr() {
var device = require('./device')()
const server = new SSDP({
location: {
port: config.PORT,
path: '/device.xml'
},
udn: `uuid:${device.deviceID}`,
allowWildcards: true,
ssdpSig: 'PsuedoTV/0.1 UPnP/1.0'
})
server.addUSN('upnp:rootdevice')
server.addUSN('urn:schemas-upnp-org:device:MediaServer:1')
server.addUSN('urn:schemas-upnp-org:service:ContentDirectory:1')
server.addUSN('urn:schemas-upnp-org:service:ConnectionManager:1')
var router = express.Router()
router.get('/device.xml', (req, res) => {
res.header("Content-Type", "application/xml")
var data = device.getXml()
res.send(data)
})
router.use(express.static('./static'))
router.get('/discover.json', (req, res) => {
res.header("Content-Type", "application/json")
res.send(JSON.stringify(device))
})
router.get('/lineup_status.json', (req, res) => {
res.header("Content-Type", "application/json")
var data = {
ScanInProgress: 0,
ScanPossible: 1,
Source: "Cable",
SourceList: ["Cable"],
}
res.send(JSON.stringify(data))
})
router.get('/lineup.json', (req, res) => {
res.header("Content-Type", "application/json")
var data = m3u.ReadChannels()
res.send(JSON.stringify(data))
})
return { router: router, ssdp: server }
}
module.exports = hdhr

View File

@ -7,13 +7,13 @@ function WriteM3U(channels, cb) {
data += `#EXTINF:0 tvg-id="${channels[i].channel}" tvg-name="${channels[i].name}" tvg-logo="${channels[i].icon}",${channels[i].channel}\n`
data += `http://${config.HOST}:${config.PORT}/video?channel=${channels[i].channel}\n`
}
fs.writeFileSync(config.M3U_OUTPUT, data)
fs.writeFileSync(config.M3U_FILE, data)
if (typeof cb == 'function')
cb()
}
// Formatted for HDHR lineup..
function ReadChannels() {
var m3uData = fs.readFileSync(config.M3U_OUTPUT)
var m3uData = fs.readFileSync(config.M3U_FILE)
var track = m3uData.toString().split(/[\n]+/)
var channels = []
track.splice(0, 1)

View File

@ -1,83 +0,0 @@
const plex = require('plex-api')
const config = require('config-yml')
const client = new plex(config.PLEX_OPTIONS)
function PlexChannelScan(cb) {
getPsuedoTVPlaylists((lineup) => {
getAllPlaylistsInfo(lineup, cb)
})
}
function getPsuedoTVPlaylists(cb) {
var lineup = []
client.query("/playlists/").then((result) => {
var playlists = result.MediaContainer
for (var i = 0; playlists.size > 0 && i < playlists.Metadata.length; i++) {
var summaryData = playlists.Metadata[i].summary.split(/\s+/)
if (playlists.Metadata[i].playlistType == 'video' && summaryData.length > 0 && summaryData[0].toLowerCase() == config.PLEX_PLAYLIST_SUMMARY_KEY) {
var channelNumber = playlists.Metadata[i].ratingKey
var channelIcon = ""
if (summaryData.length > 1) {
if (!isNaN(summaryData[1]))
channelNumber = summaryData[1]
else if (validURL(summaryData[1]))
channelIcon = summaryData[1]
}
if (summaryData.length > 2) {
if (!isNaN(summaryData[2]))
channelNumber = summaryData[2]
else if (validURL(summaryData[2]))
channelIcon = summaryData[2]
}
lineup.push({ id: playlists.Metadata[i].ratingKey, channel: channelNumber, name: playlists.Metadata[i].title, icon: channelIcon, summary: playlists.Metadata[i].summary, updatedAt: playlists.Metadata[i].updatedAt })
}
}
cb(lineup)
}, (err) => {
console.error("Could not connect to Plex server", err)
})
}
function getAllPlaylistsInfo(lineup, cb) {
var channelIndex = 0
if (lineup.length == 0)
return cb([])
var lastUpdatedAt = 0
var channelInfo = []
getPlaylist(channelIndex, () => {
cb(lineup, lastUpdatedAt, channelInfo.join())
})
// Fetch each playlist (channel) recursivley from Plex
function getPlaylist(i, _cb) {
client.query("/playlists/" + lineup[i].id + "/items").then(function (result) {
var playlist = result.MediaContainer.Metadata
lastUpdatedAt = lastUpdatedAt > lineup[i].updatedAt ? lastUpdatedAt : lineup[i].updatedAt
channelInfo.push(lineup[i].name)
channelInfo.push(lineup[i].summary)
lineup[i].duration = typeof result.MediaContainer.duration !== 'undefined' ? result.MediaContainer.duration : 0
lineup[i].playlist = typeof playlist !== 'undefined' ? playlist : []
channelIndex++
if (channelIndex < lineup.length)
getPlaylist(channelIndex, _cb)
else
_cb()
}, function (err) {
console.error("Could not connect to Plex server", err)
})
}
}
function validURL(str) {
var pattern = new RegExp('^(https?:\\/\\/)?' + // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
'(\\#[-a-z\\d_]*)?$', 'i'); // fragment locator
return !!pattern.test(str);
}
module.exports = {
PlexChannelScan: PlexChannelScan
}

96
src/plex/channels.js Normal file
View File

@ -0,0 +1,96 @@
function getPsuedoTVPlaylists(_client, cb) {
var lineup = []
_client.Get("/playlists/", (result) => {
var playlists = result.result.MediaContainer
for (var i = 0; playlists.size > 0 && i < playlists.Metadata.length; i++) {
var summaryData = playlists.Metadata[i].summary.split(/\s+/)
if (playlists.Metadata[i].playlistType == 'video' && summaryData.length > 0 && summaryData[0].toLowerCase() == 'pseudotv') {
var channelNumber = playlists.Metadata[i].ratingKey
var channelIcon = ""
var shuffle = false
if (summaryData.length > 1) {
if (!isNaN(summaryData[1]))
channelNumber = summaryData[1]
else if (validURL(summaryData[1]))
channelIcon = summaryData[1]
else if (summaryData[1] === 'shuffle')
shuffle = true
}
if (summaryData.length > 2) {
if (!isNaN(summaryData[2]))
channelNumber = summaryData[2]
else if (validURL(summaryData[2]))
channelIcon = summaryData[2]
else if (summaryData[2] === 'shuffle')
shuffle = true
}
if (summaryData.length > 3) {
if (!isNaN(summaryData[3]))
channelNumber = summaryData[3]
else if (validURL(summaryData[3]))
channelIcon = summaryData[3]
else if (summaryData[3] === 'shuffle')
shuffle = true
}
lineup.push({
id: playlists.Metadata[i].ratingKey,
channel: channelNumber,
shuffle: shuffle,
name: playlists.Metadata[i].title,
icon: channelIcon,
summary: playlists.Metadata[i].summary
})
}
}
cb(lineup)
})
}
function getAllPlaylistsInfo(_client, lineup, cb) {
var channelIndex = 0
if (lineup.length == 0)
return cb([])
getPlaylist(channelIndex, () => { cb(lineup) })
// Fetch each playlist (channel) recursivley from Plex
function getPlaylist(i, _cb) {
_client.Get("/playlists/" + lineup[i].id + "/items", (result) => {
var playlist = result.result.MediaContainer.Metadata
lineup[i].items = typeof playlist !== 'undefined' ? playlist : []
if (lineup[i].shuffle)
shuffle(lineup[i].items)
channelIndex++
if (channelIndex < lineup.length)
getPlaylist(channelIndex, _cb)
else
_cb()
})
}
}
module.exports = {
getPsuedoTVPlaylists: getPsuedoTVPlaylists,
getAllPlaylistsInfo: getAllPlaylistsInfo
}
function validURL(str) {
var pattern = new RegExp('^(https?:\\/\\/)?' + // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
'(\\#[-a-z\\d_]*)?$', 'i'); // fragment locator
return !!pattern.test(str);
}
var shuffle = function (items) {
var i = items.length
var tmp, r
while (i !== 0) {
r = Math.floor(Math.random() * i)
i--
tmp = items[i]
items[i] = items[r]
items[r] = tmp
}
return items
}

159
src/plex/index.js Normal file
View File

@ -0,0 +1,159 @@
const request = require('request')
const xmlParser = require('xml2json')
const config = require('config-yml')
const path = require('path')
const requests = require('./requests')
const playlists = require('./channels')
const plex = function (opts, callback) {
if (typeof opts.username === 'undefined' || typeof opts.password === 'undefined')
return callback({ err: "Error - No Username or Password was provided" })
if (typeof opts.hostname === 'undefined' || typeof opts.port === 'undefined')
return callback({ err: "Error - No Hostname or Port was provided" })
const OPTIONS = {
HOSTNAME: opts.hostname,
PORT: opts.port,
CLIENT_ID: 'rg14zekk3pa5zp4safjwaa8z',
PRODUCT: 'PseudoTV',
VERSION: '0.1',
DEVICE: 'PseudoTV - JS',
PLATFORM: 'Chrome',
PLATFORM_VERSION: '80.0'
}
// Login via plex.tv, return client via callback
request(requests.login(opts.username, opts.password, OPTIONS), (err, res, body) => {
if (err || res.statusCode !== 201)
return callback({ err: "Unauthorized - Username/Email and Password is incorrect!." })
var _client = client()
_client.authToken = JSON.parse(body).user.authToken
_client.Get('/', (result) => {
if (result.err)
return callback({ err: "Failed to connect to server." })
_client._poll()
_client.serverId = result.result.MediaContainer.machineIdentifier
callback({ client: _client })
})
})
const client = function () {
var _this = this
_this.OPTIONS = OPTIONS
// Private
_this._killed = false
_this.serverId = ''
_this.authToken = ''
_this._poll = function () {
request(requests.poll(_this), (err, res) => {
if (!_this._killed && !err && res.statusCode === 200)
_this._poll() // recurse, plex returns response every 20 seconds.
})
}
_this._updateTimeline = (item, playQueueItemID, state, time, cb) => {
var callback = typeof cb === 'function' ? cb : () => { }
request(requests.timeline(_this, item, playQueueItemID, state, time), (err, res) => {
if (err || res.statusCode !== 200)
callback({ err: "Get Request Failed" })
else
callback({ result: JSON.parse(xmlParser.toJson(res.body, { arrayNotation: true })) })
})
}
_this._createPlayQueue = function (key, callback) {
request(requests.queue(_this, key), (err, res) => {
if (err && res.statusCode !== 200)
callback({ err: "Post Request Failed" })
else
callback({ result: JSON.parse(res.body) })
})
}
_this._getTranscodeURL = (item) => {
return requests.transcode(_this, item)
}
_this._refreshGuide = (dvrID) => {
request(requests.refreshGuide(_this, dvrID))
}
_this._refreshChannels = (dvrID, channels) => {
request(requests.refreshChannels(_this, dvrID, channels))
}
// Public
_this.Close = () => { _this._killed = true }
_this.Get = function (path, callback) {
request(requests.get(_this, path), (err, res) => {
if (err || res.statusCode !== 200)
callback({ err: "Get Request Failed" })
else
callback({ result: JSON.parse(res.body) })
})
}
_this.Transcode = function (item, msElapsed, cb) {
_createPlayQueue(item.key, (res) => {
if (res.err)
cb(res)
var playQueueID = res.result.MediaContainer.playQueueID
_updateTimeline(item, playQueueID, 'playing', msElapsed)
var stop = () => { _updateTimeline(item, playQueueID, 'stopped', 0) }
var update = (_msElapsed) => { _updateTimeline(item, playQueueID, 'playing', _msElapsed) }
cb({ result: { url: _getTranscodeURL(item), stop: stop, update: update }})
})
}
_this.PseudoTVChannelScan = function (cb) {
playlists.getPsuedoTVPlaylists(_this, (lineup) => {
playlists.getAllPlaylistsInfo(_this, lineup, cb)
})
}
_this.RefreshGuide = function () {
GetPseudoTVDVRS((result) => {
var dvrs = result.result
dvrs = typeof dvrs === 'undefined' ? [] : dvrs
for (var i = 0; i < dvrs.length; i++) {
var xmlfile = dvrs[i].lineup.split('lineup://tv.plex.providers.epg.xmltv/')
xmlfile = xmlfile[xmlfile.length - 1].split('#')[0]
if (path.resolve(xmlfile) === path.resolve(config.XMLTV_FILE)) {
_refreshGuide(dvrs[i].key)
}
}
})
}
_this.RefreshChannels = function (channels) {
GetPseudoTVDVRS((result) => {
var dvrs = result.result
dvrs = typeof dvrs === 'undefined' ? [] : dvrs
for (var i = 0; i < dvrs.length; i++) {
for (var y = 0; y < dvrs[i].Device.length; y++) {
_refreshChannels(dvrs[i].Device[y].key, channels)
}
}
})
}
_this.GetPseudoTVDVRS = function (cb) {
Get('/livetv/dvrs', (result) => {
if (result.err)
return cb(result)
var dvrs = result.result.MediaContainer.Dvr
dvrs = typeof dvrs === 'undefined' ? [] : dvrs
var _dvrs = []
for (var i = 0; i < dvrs.length; i++) {
var xmlfile = dvrs[i].lineup.split('lineup://tv.plex.providers.epg.xmltv/')
xmlfile = xmlfile[xmlfile.length - 1].split('#')[0]
if (path.resolve(xmlfile) === path.resolve(config.XMLTV_FILE))
_dvrs.push(dvrs[i])
}
cb({result: _dvrs})
})
}
return _this
}
}
module.exports = plex

249
src/plex/requests.js Normal file
View File

@ -0,0 +1,249 @@
const queryString = require('query-string')
const config = require('config-yml')
module.exports = {
login: login,
poll: poll,
get: get,
timeline: timeline,
queue: queue,
transcode: transcode,
refreshGuide: refreshGuide,
refreshChannels: refreshChannels
}
function login(username, password, OPTIONS) {
return {
method: 'post',
url: 'https://plex.tv/users/sign_in.json',
headers: {
'X-Plex-Platform': OPTIONS.PLATFORM,
'X-Plex-Platform-Version': OPTIONS.PLATFORM_VERSION,
'X-Plex-Provides': 'timeline,playback,navigation,mirror,playqueues',
'X-Plex-Client-Identifier': OPTIONS.CLIENT_ID,
'X-Plex-Product': OPTIONS.PRODUCT,
'X-Plex-Version': OPTIONS.VERSION,
'X-Plex-Device': OPTIONS.DEVICE,
'X-Plex-Device-Name': OPTIONS.DEVICE
},
form: {
user: {
login: username,
password: password
}
}
}
}
function poll(_client) {
return {
method: 'get',
url: `http://${OPTIONS.HOSTNAME}:${OPTIONS.PORT}/player/proxy/poll`,
qs: {
deviceClass: 'pc',
protocolVersion: 3,
protocolCapabilities: 'timeline,playback,navigation,mirror,playqueues',
timeout: 1,
'X-Plex-Provides': 'timeline,playback,navigation,mirror,playqueues',
'X-Plex-Product': _client.OPTIONS.PRODUCT,
'X-Plex-Version': _client.OPTIONS.VERSION,
'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID,
'X-Plex-Platform': _client.OPTIONS.PLATFORM,
'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION,
'X-Plex-Sync-Version': 2,
'X-Plex-Features': 'external-media,internal-media',
'X-Plex-Device': _client.OPTIONS.DEVICE,
'X-Plex-Device-Name': _client.OPTIONS.DEVICE,
'X-Plex-Token': _client.authToken
}
}
}
function get(_client, path) {
return {
method: 'get',
url: `http://${OPTIONS.HOSTNAME}:${OPTIONS.PORT}${path}`,
headers: {
'Accept': 'application/json',
'X-Plex-Token': _client.authToken,
'X-Plex-Device': _client.OPTIONS.DEVICE,
'X-Plex-Device-Name': _client.OPTIONS.DEVICE,
'X-Plex-Product': _client.OPTIONS.PRODUCT,
'X-Plex-Version': _client.OPTIONS.VERSION,
'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID,
'X-Plex-Platform': _client.OPTIONS.PLATFORM,
'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION,
}
}
}
function timeline(_client, item, pQid, state, time) {
return {
method: 'get',
url: `http://${_client.OPTIONS.HOSTNAME}:${_client.OPTIONS.PORT}/:/timeline`,
qs: {
ratingKey: item.ratingKey,
key: item.key,
playbackTime: 0,
playQueueItemID: pQid,
state: state,
hasMDE: 1,
time: time,
duration: item.duration,
'X-Plex-Session-Identifier': config.PLEX_SESSION_ID,
'X-Plex-Token': _client.authToken,
'X-Plex-Device': _client.OPTIONS.DEVICE,
'X-Plex-Device-Name': _client.OPTIONS.DEVICE,
'X-Plex-Product': _client.OPTIONS.PRODUCT,
'X-Plex-Version': _client.OPTIONS.VERSION,
'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID,
'X-Plex-Platform': _client.OPTIONS.PLATFORM,
'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION,
'X-Plex-Sync-Version': 2,
'X-Plex-Features': 'external-media,indirect-media',
'X-Plex-Model': 'bundled',
'X-Plex-Device-Screen-Resolution': '1920x1080',
'X-Plex-Language': 'en',
'X-Plex-Text-Format': 'plain',
'X-Plex-Provider-Version': '1.3',
'X-Plex-Drm': 'widevine'
}
}
}
function queue(_client, key) {
return {
method: 'post',
url: `http://${_client.OPTIONS.HOSTNAME}:${_client.OPTIONS.PORT}/playQueues`,
headers: {
'Accept': 'application/json',
},
qs: {
type: 'video',
extrasPrefixCount: 0,
uri: `server://${_client.serverId}/com.plexapp.plugins.library${key}`,
repeat: 0,
own: 1,
includeChapters: 1,
includeGeolocation: 1,
includeExternalMedia: 1,
'X-Plex-Token': _client.authToken,
'X-Plex-Device': _client.OPTIONS.DEVICE,
'X-Plex-Device-Name': _client.OPTIONS.DEVICE,
'X-Plex-Product': _client.OPTIONS.PRODUCT,
'X-Plex-Version': _client.OPTIONS.VERSION,
'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID,
'X-Plex-Platform': _client.OPTIONS.PLATFORM,
'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION,
'X-Plex-Sync-Version': 2,
'X-Plex-Features': 'external-media,indirect-media',
'X-Plex-Model': 'bundled',
'X-Plex-Device-Screen-Resolution': '1920x1080',
'X-Plex-Language': 'en',
'X-Plex-Text-Format': 'plain',
'X-Plex-Provider-Version': '1.3',
'X-Plex-Drm': 'widevine'
}
}
}
function transcode(_client, item) {
return queryString.stringifyUrl({
url: `http://${_client.OPTIONS.HOSTNAME}:${_client.OPTIONS.PORT}/video/:/transcode/universal/start.mpd`,
query: {
hasMDE: 1,
path: item.key,
mediaIndex: 0,
partIndex: 0,
protocol: 'dash',
fastSeek: 1,
directPlay: 0,
directStream: 0,
subtitleSize: 100,
audioBoost: 100,
location: 'lan',
addDebugOverlay: 0,
autoAdjustQuality: 0,
directStreamAudio: 1,
mediaBufferSize: 102400,
session: 'wtfisthisusedfor',
subtitles: 'burn',
'Accept-Language': 'en',
'X-Plex-Session-Identifier': config.PLEX_SESSION_ID,
'X-Plex-Client-Profile-Extra': 'append-transcode-target-codec(type=videoProfile&context=streaming&audioCodec=aac&protocol=dash)',
//'X-Plex-Client-Profile-Extra': 'add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.bitrate&value=4000&replace=true)+append-transcode-target-codec(type=videoProfile&context=streaming&audioCodec=aac&protocol=dash)',
'X-Plex-Product': _client.OPTIONS.PRODUCT,
'X-Plex-Version': _client.OPTIONS.VERSION,
'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID,
'X-Plex-Platform': _client.OPTIONS.PLATFORM,
'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION,
'X-Plex-Sync-Version': 2,
'X-Plex-Features': 'external-media,indirect-media',
'X-Plex-Device': _client.OPTIONS.DEVICE,
'X-Plex-Device-Name': _client.OPTIONS.DEVICE,
'X-Plex-Model': 'bundled',
'X-Plex-Device-Screen-Resolution': '1920x1080,1920x1080',
'X-Plex-Token': _client.authToken,
'X-Plex-Language': 'en'
}
})
}
function refreshGuide(_client, dvrID) {
return {
method: 'post',
url: `http://${_client.OPTIONS.HOSTNAME}:${_client.OPTIONS.PORT}/livetv/dvrs/${dvrID}/reloadGuide`,
headers: {
'Accept': 'application/json',
},
qs: {
'X-Plex-Product': _client.OPTIONS.PRODUCT,
'X-Plex-Version': _client.OPTIONS.VERSION,
'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID,
'X-Plex-Platform': _client.OPTIONS.PLATFORM,
'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION,
'X-Plex-Sync-Version': 2,
'X-Plex-Features': 'external-media,indirect-media',
'X-Plex-Model': 'bundled',
'X-Plex-Device': _client.OPTIONS.DEVICE,
'X-Plex-Device-Name': _client.OPTIONS.DEVICE,
'X-Plex-Device-Screen-Resolution': '1920x1080,1920x1080',
'X-Plex-Token': _client.authToken,
'X-Plex-Language': 'en'
}
}
}
function refreshChannels(_client, dvrID, channels) {
var qs = {
'X-Plex-Product': _client.OPTIONS.PRODUCT,
'X-Plex-Version': _client.OPTIONS.VERSION,
'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID,
'X-Plex-Platform': _client.OPTIONS.PLATFORM,
'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION,
'X-Plex-Sync-Version': 2,
'X-Plex-Features': 'external-media,indirect-media',
'X-Plex-Model': 'bundled',
'X-Plex-Device': _client.OPTIONS.DEVICE,
'X-Plex-Device-Name': _client.OPTIONS.DEVICE,
'X-Plex-Device-Screen-Resolution': '1920x1080,1920x1080',
'X-Plex-Token': _client.authToken,
'X-Plex-Language': 'en'
}
var _channels = []
for (var i = 0; i < channels.length; i ++)
_channels.push(channels[i].channel)
qs.channelsEnabled = _channels.join(',')
for (var i = 0; i < _channels.length; i ++) {
qs[`channelMapping[${_channels[i]}]`] = _channels[i]
qs[`channelMappingByKey[${_channels[i]}]`] = _channels[i]
}
return {
method: 'put',
url: `http://${_client.OPTIONS.HOSTNAME}:${_client.OPTIONS.PORT}/media/grabbers/devices/${dvrID}/channelmap`,
headers: {
'Accept': 'application/json',
},
qs: qs
}
}

251
src/pseudotv.js Normal file
View File

@ -0,0 +1,251 @@
const Router = require('express').Router
const config = require('config-yml')
const path = require('path')
module.exports = pseudotv
function pseudotv(client, xmltv, m3u) {
var counter = config.EPG_UPDATE * 60 * 1000
restartEPG(client, xmltv, m3u, config.PLEX_AUTO_REFRESH_GUIDE, config.PLEX_AUTO_REMAP_CHANNELS, () => {
console.log("Initial EPG Generated.")
})
if (config.EPG_UPDATE !== 0) {
setInterval(() => {
counter -= 1000
if (counter === 0)
updateEPG(client, xmltv, m3u, config.PLEX_AUTO_REFRESH_GUIDE, config.PLEX_AUTO_REMAP_CHANNELS, () => {
console.log("Updated EPG via Scheduled Refresh.")
counter = config.EPG_UPDATE * 60 * 1000
})
}, 1000)
}
var router = Router()
router.get('/', (req, res) => {
if (req.query.refresh === 'true')
updateEPG(client, xmltv, m3u,
req.query.rg === 'true' ? true : false,
req.query.rc === 'true' ? true : false, () => {
counter = config.EPG_UPDATE * 60 * 1000
return res.status(200).send()
})
else if (req.query.restart === 'true')
restartEPG(client, xmltv, m3u,
req.query.rg === 'true' ? true : false,
req.query.rc === 'true' ? true : false, () => {
counter = config.EPG_UPDATE * 60 * 1000
return res.status(200).send()
})
else
client.GetPseudoTVDVRS((result) => {
res.status(200).send(createHTML(result.result, xmltv.readXMLChannels(), counter / 1000))
})
})
return router
}
function updateEPG(client, xmltv, m3u, rg, rc, cb) {
client.PseudoTVChannelScan((channels) => {
xmltv.UpdateXMLTV(channels, () => {
m3u.WriteM3U(channels, () => {
if (rg)
client.RefreshGuide()
if (rc)
client.RefreshChannels(channels)
cb()
})
})
})
}
function restartEPG(client, xmltv, m3u, rg, rc, cb) {
client.PseudoTVChannelScan((channels) => {
xmltv.WriteXMLTV(channels, () => {
m3u.WriteM3U(channels, () => {
if (rg)
client.RefreshGuide()
if (rc)
client.RefreshChannels(channels)
cb()
})
})
})
}
function createHTML(dvrs, channels, counter) {
var str = `
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pseudotv-plex</title>
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet">
<link href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
</head>
<body class="bg-dark">
<div class="container bg-light" style="padding: 15px; margin: 5px auto;">
<h1>pseudotv-plex<span class="pull-right"><a href="https://gitlab.com/DEFENDORe/pseudotv-plex"><i class="fa fa-gitlab"></i></a></span></h1>
<p class="lead">Create live TV channels from your Plex playlists.</p>
</div>
<div class="container bg-light" style="padding: 15px; margin: 5px auto;" id="channels">
<h3 class="text-center">Pseudo Channels</h3>
<hr/>
<span class="text-md-left">Total channels in XMLTV file: <b>${channels.length}</b></span>
${createChannelTable(channels)}
</div>
<div class="container bg-light" style="padding: 15px; margin: 5px auto;" id="epg">
<h3 class="text-center">EPG Utility</h3>
<p class="lead text-center">Scan Plex for <i>${config.PLEX_PLAYLIST_IDENTIFIER}</i> playlists.</p>
<hr/>
${createEPGUtility(dvrs)}
</div>
<div class="container bg-light" style="padding: 15px; margin: 5px auto;" id="config">
<h3 class="text-center">Configuration</h3>
<p class="text-center text-secondary">Any changes made to <b>config.yml</b> won't take effect until pseudotv-plex is restarted.</p>
<hr/>
${createConfigDetails()}
</div>
<p class="text-center">Author: Dan Ferguson</p>
${createScript(counter)}
</body>
</html>`
return str.split(' ').join('')
}
function createConfigDetails() {
var str = `<p>
Host: <b>${config.HOST}</b><br/>
Port: <b>${config.PORT}</b>
<hr/>
Plex Server Host: <b>${config.PLEX_OPTIONS.hostname}</b><br/>
Plex Server Port: <b>${config.PLEX_OPTIONS.port}</b><hr/>
XMLTV: <b>${path.resolve(config.XMLTV_FILE)}</b><br/>
M3U: <b>${path.resolve(config.M3U_FILE)}</b>
<hr/>
${config.HDHOMERUN_OPTIONS.ENABLED ? `HDHomeRun Tuner: <b>${config.HOST}:${config.PORT}</b><br/>` : ''}
HDHomeRun Tuner: <b>${config.HDHOMERUN_OPTIONS.ENABLED ? 'Enabled' : 'Disabled'}</b>
${config.HDHOMERUN_OPTIONS.ENABLED ? `<br/>HDHomeRun Auto-Discovery: <b>${config.HDHOMERUN_OPTIONS.AUTODISCOVERY ? 'Enabled' : 'Disabled'}</b>` : ''}
<hr/>
MPEGTS Streaming Muxer: <b>${config.MUXER.toUpperCase()}</b><br/>
${config.MUXER.toUpperCase()} Location: <b>${config.MUXER.toLowerCase() === 'ffmpeg' ? path.resolve(config.FFMPEG_OPTIONS.PATH) : path.resolve(config.VLC_OPTIONS.PATH)}</b>
${config.MUXER.toLowerCase() === 'ffmpeg' ? `<br/>FFMPEG Prebuffering: <b>${config.FFMPEG_OPTIONS.PREBUFFER ? 'Enabled' : 'Disabled'}</b>` : ''}
${config.MUXER.toLowerCase() === 'vlc' ? `<br/>VLC HTTP Server Port: <b>${config.VLC_OPTIONS.PORT}</b>` : ''}
${config.MUXER.toLowerCase() === 'vlc' ? `<br/>VLC Streaming Delay (ms): <b>${config.VLC_OPTIONS.DELAY}</b>` : ''}
${config.MUXER.toLowerCase() === 'vlc' ? `<br/>VLC Session Visibility: <b>${config.VLC_OPTIONS.HIDDEN ? 'Hidden' : 'Visible'}</b>` : ''}
<hr/>
EPG Cache: <b>${config.EPG_CACHE} Hours</b><br/>
EPG Update: <b>${config.EPG_UPDATE === 0 ? 'Never' : config.EPG_UPDATE + ' Minutes'}</b><br/>
Auto Refresh Plex Guide: <b>${config.PLEX_AUTO_REFRESH_GUIDE ? 'Yes' : 'No'}</b><br/>
Auto Refresh Plex DVR Channels: <b>${config.PLEX_AUTO_REMAP_CHANNELS ? 'Yes' : 'No'}</b>
<hr/>
Plex Playlist Summary Identifier: <b>${config.PLEX_PLAYLIST_IDENTIFIER}</b><br/>
X-Plex-Session-Identifier: <b>${config.PLEX_SESSION_ID}</b>
</p>`
return str.split(' ').join('')
}
var createChannelTable = (channels) => {
var str = `<table class="table table-striped">
<tr>
<th>#</th>
<th>Name</th>
<th>Icon</th>
<th>Shuffle</th>
</tr>`
for (var i = 0; i < channels.length; i++) {
str += `<tr>
<td>${channels[i].channel}</td>
<td>${channels[i].name}</td>
<td>${channels[i].icon ? `<img style="height: 50px;" src="${channels[i].icon}"/>` : ''}</td>
<td>${channels[i].shuffle}</td>
</tr>`
}
str += `</table>`
if (channels.length === 0) {
str += `<h5>Initial Setup</h5>
<ol>
<li>Create a video playlist. <i>Tip: you can utilize Plex Smart Playlist's to create dynamic channels.</i></li>
<li>Edit the playlist's summary/description, write <i>${config.PLEX_PLAYLIST_IDENTIFIER}</i> at the beginning to identify the playlist as a channel.</li>
<li>Restart pseudotv-plex, or use the '<a href="#epg">EPG Utilty</a>' below to update/restart your EPG.</li>
<li>Add the spoofed HDHomeRun tuner to Plex. Use the XMLTV file for EPG information. Alternatively you can use xTeVe, by utilizing the XMLTV and M3U files.</li>
<li>Enjoy your pseudo live TV</li>`
}
return str.split(' ').join('')
}
function createEPGUtility(dvrs) {
var str = `<form id="frmEPG" class="text-center" onsubmit="return false">
<p><b>Plex Server LiveTV/DVR Auto Refresh Options</b>${dvrs.length > 0 ? '' : '<br/><span class="text-info">(Could not find a PseudoTV DVR in Plex)</span>'}</p>
<div class="row">
<label for="chkGuide" class="col-md-6">
<input type="checkbox" id="chkGuide" ${config.PLEX_AUTO_REFRESH_GUIDE ? 'checked' : ''} ${dvrs.length > 0 ? 'enabled' : 'disabled'}> Auto Refresh Guide
</label>
<label for="chkChannels" class="col-md-6">
<input type="checkbox" id="chkChannels" ${config.PLEX_AUTO_REMAP_CHANNELS ? 'checked' : ''} ${dvrs.length > 0 ? 'enabled' : 'disabled'}> Auto Remap Channels
</label>
</div>
<hr/>
<div class="row">
<div class="col-md-6">
<button id="btnRefresh" type="submit" class="btn btn-primary" style="width: 100%">Refresh EPG</button>
<p class="text-primary">Updates the XMLTV file in such a way that channel timelines are uninterupted, if possible.</p>
</div>
<div class="col-md-6">
<button id="btnRestart" type="submit" class="btn btn-danger" style="width: 100%">Restart EPG</button>
<p class="text-danger">Rewrites the XMLTV file, every channels timeline will begin now.</p>
</div>
</div>
</form>
<p class="text-center"><i>Restart Plex client apps and refresh Plex web sessions to view changes..</i></p>
${config.EPG_UPDATE === 0 ? '' : `<hr/><p class="text-center"><span><b>Next EPG Refresh:</b> <span style="width: 100px; display: inline-block;" class="counter text-right"></span> (hh:mm:ss)</span></p>`}
`
return str.split(' ').join('')
}
var createScript = function (counter) {
var str = `<script>
var chkGuide = document.getElementById('chkGuide')
var chkChannels = document.getElementById('chkChannels')
var btnRefresh = document.getElementById('btnRefresh')
var btnRestart = document.getElementById('btnRestart')
${config.EPG_UPDATE === 0 ? '' : `
function ts(s) {
var date = new Date(null)
date.setSeconds(s)
return date.toISOString().substr(11, 8)
}
var x = document.getElementsByClassName('counter')
var secs = ${counter}
for (var i = 0; i < x.length; i++)
x[i].innerText = ts(secs)
var interval = setInterval(() => {
secs--
for (var i = 0; i < x.length; i++)
x[i].innerText = ts(secs)
if (secs <= 0) {
clearInterval(interval)
setTimeout(() => { location.reload() }, 1000)
}
}, 1000)
`}
btnRefresh.addEventListener('click', () => {
var xhr = new XMLHttpRequest()
xhr.open('GET', '/?refresh=true&rg=' + (chkGuide.checked ? 'true' : 'false') + '&rc=' + (chkChannels.checked ? 'true' : 'false'), true)
xhr.onload = function () {
location.reload()
}
xhr.send(null)
})
btnRestart.addEventListener('click', () => {
var xhr = new XMLHttpRequest()
xhr.open('GET', '/?restart=true&rg=' + (chkGuide.checked ? 'true' : 'false') + '&rc=' + (chkChannels.checked ? 'true' : 'false'), true)
xhr.onload = function () {
location.reload()
}
xhr.send(null)
})
</script>
`
return str
}

View File

@ -5,77 +5,143 @@ const config = require('config-yml')
const xmltv = require('./xmltv')
module.exports = { router: vlcRouter }
module.exports = vlcRouter
function vlcRouter() {
function vlcRouter(client) {
var router = Router()
var streams = []
var inUse = false
router.get('/video', (req, res) => {
var programs = xmltv.readXMLPrograms()
if (!req.query.channel)
return res.status(422).send("No channel queried")
req.query.channel = req.query.channel.split('?')[0]
var streamIndex = -1
for (var i = 0; i < streams.length; i++) {
if (streams[i].channel === req.query.channel) {
streamIndex = i
break
}
if (inUse)
return res.status(409).send("Error: Another user is currently viewing a stream. One one active stream is allowed.")
inUse = true
var channel = req.query.channel
if (!channel) {
inUse = false
res.status(400).send("Error: No channel queried")
return
}
channel = channel.split('?')[0]
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-disposition': 'attachment; filename=video.ts'
})
startStreaming(channel, res)
})
if (streamIndex != -1) {
streams[streamIndex].viewers++
request('http://' + config.HOST + ':' + streams[streamIndex].port + '/').on('error', (err) => {/* ignore errors */}).pipe(res)
} else {
var args = []
var startPos = 0
var programIndex = 0
for (var i = 0; i < programs.length; i++) {
var date = new Date()
if (programs[i].start <= date && programs[i].stop >= date && programs[i].channel == req.query.channel) {
var dif = date.getTime() - programs[i].start.getTime()
startPos = dif / 1000
return router
function startStreaming(channel, res) {
var programs = xmltv.readXMLPrograms()
var startPos = -1
var programIndex = -1
var channelExists = false
for (var i = 0; i < programs.length; i++) {
var date = new Date()
if (programs[i].channel == channel) {
channelExists = true
if (programs[i].start <= date && programs[i].stop >= date) {
startPos = date.getTime() - programs[i].start.getTime()
programIndex = i
break
}
}
for (var i = programIndex; i < programs.length; i++)
if (programs[i].channel == req.query.channel)
args.push(programs[i].video)
if (args.length == 0)
return res.status(422).send("Channel not found")
var vlcPort = config.PORT + streams.length + 1
args.push("--start-time=" + startPos)
if (programs.optimized)
args.push(`--sout=#http{mux=ts,dst=:${vlcPort}/}`)
else
args.push(`--sout=#${config.VLC_TRANSCODE_SETTINGS}:http{mux=ts,dst=:${vlcPort}/}`)
if (config.VLC_HIDDEN)
args.push("--intf=dummy")
var vlcExe = spawn(config.VLC_EXECUTABLE, args)
var stream = { vlcExe: vlcExe, channel: req.query.channel, viewers: 1, port: vlcPort }
streamIndex = streams.length
streams.push(stream)
setTimeout(() => {
request(`http://${config.HOST}:${vlcPort}/`).on('error', function (err) {/* ignore errors */}).pipe(res)
}, config.VLC_STARTUP_DELAY)
}
res.on('close', () => {
streams[streamIndex].viewers--
if (streams[streamIndex].viewers == 0) {
streams[streamIndex].vlcExe.kill()
streams.splice(streamIndex, 1)
// End session if any errors.
if (!channelExists) {
inUse = false
res.status(403).send(`Error: Channel doesn't exist. Channel: ${channel}`)
return
}
if (programIndex === -1) {
inUse = false
res.status(403).send(`Error: No scheduled programming available. Channel: ${channel}`)
return
}
if (startPos === -1) {
inUse = false
res.status(403).send(`Error: How the fuck did you get here?. Channel: ${channel}`)
return
}
// Query plex for current program
client.Get(programs[programIndex].key, (result) => {
if (result.err) {
inUse = false
res.status(403).send(`Error: Failed to fetch program info from Plex`)
return
}
})
})
var fetchedItem = result.result.MediaContainer.Metadata[0]
// Transcode it
client.Transcode(fetchedItem, startPos, (result) => {
if (result.err) {
inUse = false
res.status(403).send(`Error: Failed to add program to playQueue`)
return
}
// Update server timeline every 10 seconds
var stream = result.result
var msElapsed = startPos
var timelineInterval = setInterval(() => {
stream.update(msElapsed)
msElapsed += 10000
}, 10000)
var args = [
stream.url,
`--start-time=${(startPos + config.VLC_OPTIONS.DELAY) / 1000}`,
`--sout=#http{mux=ts,dst=:${config.VLC_OPTIONS.PORT}/}`
]
if (config.VLC_OPTIONS.HIDDEN)
args.push("--intf=dummy")
// Fire up VLC
var vlc = spawn(config.VLC_OPTIONS.PATH, args)
// Wait for VLC to open before we request anything.
setTimeout(() => {
request(`http://${config.HOST}:${config.VLC_OPTIONS.PORT}/`)
.on('error', (err) => {
vlc.kill()
if (err.code === 'ECONNRESET') {
var end = programs[programIndex].stop
var now = new Date()
var timeUntilDone = end.valueOf() - now.valueOf()
timeUntilDone = timeUntilDone > 0 ? timeUntilDone : 0
setTimeout(() => {
res.removeListener('close', httpEnd)
startStreaming(channel, res)
}, timeUntilDone)
}
})
.on('data', (chunk) => {
res.write(chunk)
})
.on("complete", () => {
vlc.kill()
var end = programs[programIndex].stop
var now = new Date()
var timeUntilDone = end.valueOf() - now.valueOf()
timeUntilDone = timeUntilDone > 0 ? timeUntilDone : 0
setTimeout(() => {
res.removeListener('close', httpEnd)
startStreaming(channel, res)
}, timeUntilDone)
})
}, config.VLC_OPTIONS.DELAY)
// When the http session ends: kill vlc
var httpEnd = function () {
vlc.kill()
inUse = false
}
res.on('close', httpEnd)
return router
vlc.on('close', (code) => {
clearInterval(timelineInterval)
stream.stop()
if (code !== 0 && !res.headersSent) {
res.status(400).send(`Error: VLC closed unexpectedly`)
}
})
})
})
}
}

View File

@ -3,8 +3,15 @@ const XMLReader = require('xml-reader')
const fs = require('fs')
const config = require('config-yml')
module.exports = {
WriteXMLTV: WriteXMLTV,
UpdateXMLTV: UpdateXMLTV,
readXMLPrograms: readXMLPrograms,
readXMLChannels: readXMLChannels
}
function readXMLPrograms() {
var data = fs.readFileSync(config.XMLTV_OUTPUT)
var data = fs.readFileSync(config.XMLTV_FILE)
var xmltv = XMLReader.parseSync(data.toString())
var programs = []
var tv = xmltv.children
@ -15,16 +22,42 @@ function readXMLPrograms() {
channel: tv[i].attributes.channel,
start: createDate(tv[i].attributes.start),
stop: createDate(tv[i].attributes.stop),
video: tv[i].attributes.video,
optimized: tv[i].attributes.optimized == "true" ? true : false
key: tv[i].attributes['plex-key']
}
programs.push(program)
}
return programs
}
function readXMLChannels() {
var data = fs.readFileSync(config.XMLTV_FILE)
var xmltv = XMLReader.parseSync(data.toString())
var channels = []
var tv = xmltv.children
for (var i = 0; i < tv.length; i++) {
if (tv[i].name == 'programme')
continue;
//console.log(tv[i])
var channel = {
channel: tv[i].attributes.id,
shuffle: tv[i].attributes.shuffle
}
for (var y = 0; y < tv[i].children.length; y++)
{
if (tv[i].children[y].name === 'display-name') {
channel.name = tv[i].children[y].children[0].value
}
if (tv[i].children[y].name === 'icon') {
channel.icon = tv[i].children[y].attributes.src
}
}
channels.push(channel)
}
return channels
}
function WriteXMLTV(channels, cb) {
var xw = new XMLWriter(true);
var xw = new XMLWriter(true)
var time = new Date()
// Build XMLTV and M3U files
xw.startDocument()
@ -32,57 +65,45 @@ function WriteXMLTV(channels, cb) {
xw.startElement('tv')
xw.writeAttribute('generator-info-name', 'psuedotv-plex')
writeChannels(xw, channels)
// Programmes
// For each channel
for (var i = 0; i < channels.length; i++) {
var future = new Date()
future.setHours(time.getHours() + config.EPG_CACHE)
var tempDate = new Date(time.valueOf())
while (tempDate < future && channels[i].playlist.length > 0) {
for (var y = 0; y < channels[i].playlist.length; y++) {
// Loop items until EPG_CACHE is satisfied, starting time of first show is NOW.
while (tempDate < future && channels[i].items.length > 0) {
for (var y = 0; y < channels[i].items.length && tempDate < future; y++) {
var stopDate = new Date(tempDate.valueOf())
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].playlist[y].duration)
var plexURL = channels[i].playlist[y].Media[0].Part[0].key
var optimizedForStreaming = false
for (var z = 0; z < channels[i].playlist[y].Media.length; z++) {
var part = channels[i].playlist[y].Media[z].Part[0]
if (typeof part.optimizedForStreaming !== 'undefined' && part.optimizedForStreaming) {
plexURL = part.key
optimizedForStreaming = part.optimizedForStreaming
break;
}
}
plexURL = `http://${config.PLEX_OPTIONS.hostname}:${config.PLEX_OPTIONS.port}${plexURL}?X-Plex-Token=${config.PLEX_OPTIONS.token}`
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].items[y].duration)
var program = {
info: channels[i].playlist[y],
info: channels[i].items[y],
channel: channels[i].channel,
start: new Date(tempDate.valueOf()),
stop: stopDate,
plexURL: plexURL,
optimizedForStreaming: optimizedForStreaming.toString()
stop: stopDate
}
writeProgramme(xw, program)
tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].playlist[y].duration)
tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].items[y].duration)
}
}
}
// End TV
xw.endElement()
xw.endDocument()
fs.writeFileSync(config.XMLTV_OUTPUT, xw.toString())
fs.writeFileSync(config.XMLTV_FILE, xw.toString())
if (typeof cb == 'function')
cb()
}
function UpdateXMLTV(channels, cb) {
var xw = new XMLWriter(true)
var data = fs.readFileSync(config.XMLTV_OUTPUT)
var data = fs.readFileSync(config.XMLTV_FILE)
var xml = XMLReader.parseSync(data.toString())
var time = new Date()
xw.startDocument()
xw.startElement('tv')
xw.writeAttribute('generator-info-name', 'psuedotv-plex')
writeChannels(xw, channels)
// Programmes
// Foreach channel
for (var i = 0; i < channels.length; i++) {
// get non-expired programmes for channel
var validPrograms = []
@ -94,97 +115,67 @@ function UpdateXMLTV(channels, cb) {
}
}
// If Channel doesnt exists..
if (validPrograms.length == 0) {
// write out programs from plex
if (validPrograms.length === 0) {
var future = new Date()
future.setHours(time.getHours() + config.EPG_CACHE)
var tempDate = new Date(time.valueOf())
// Loop items until EPG_CACHE is satisfied, starting time of first show is NOW.
while (tempDate < future) {
for (var y = 0; y < channels[i].playlist.length; y++) { // foreach item in playlist
for (var y = 0; y < channels[i].items.length && tempDate < future; y++) { // foreach item in playlist
var stopDate = new Date(tempDate.valueOf())
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].playlist[y].duration)
var plexURL = channels[i].playlist[y].Media[0].Part[0].key
var optimizedForStreaming = false
for (var z = 0; z < channels[i].playlist[y].Media.length; z++) { // get optimed video if there is one
var part = channels[i].playlist[y].Media[z].Part[0]
if (typeof part.optimizedForStreaming !== 'undefined' && part.optimizedForStreaming) {
plexURL = part.key
optimizedForStreaming = part.optimizedForStreaming
break;
}
}
plexURL = `http://${config.PLEX_OPTIONS.hostname}:${config.PLEX_OPTIONS.port}${plexURL}?X-Plex-Token=${config.PLEX_OPTIONS.token}`
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].items[y].duration)
var program = {
info: channels[i].playlist[y],
info: channels[i].items[y],
channel: channels[i].channel,
start: new Date(tempDate.valueOf()),
stop: stopDate,
plexURL: plexURL,
optimizedForStreaming: optimizedForStreaming.toString()
stop: stopDate
}
writeProgramme(xw, program)
tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].playlist[y].duration)
tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].items[y].duration)
}
}
} else {
var playlistStartIndex = 0
var isFirstItemFound = false
} else { // Otherwise the channel already exists..
var playlistStartIndex = -1
var startingDate = new Date(time.valueOf())
var endDate = new Date(time.valueOf())
endDate.setHours(endDate.getHours() + config.EPG_CACHE)
// rewrite first valid xml programmes, if it still exists in the plex playlist..
for (var z = 0; z < channels[i].playlist.length; z++) {
if (channels[i].playlist[z].guid == validPrograms[0].attributes.guid) {
isFirstItemFound = true
for (var z = 0; z < channels[i].items.length; z++) {
if (channels[i].items[z].key == validPrograms[0].attributes['plex-key']) {
playlistStartIndex = z
var program = {
channel: validPrograms[0].attributes.channel,
start: createDate(validPrograms[0].attributes.start),
stop: createDate(validPrograms[0].attributes.stop),
plexURL: validPrograms[0].attributes.video,
optimizedForStreaming: validPrograms[0].attributes.optimized,
info: channels[i].playlist[z]
info: channels[i].items[z]
}
startingDate = new Date(program.stop.valueOf())
writeProgramme(xw, program)
break;
}
}
if (isFirstItemFound) {
if (playlistStartIndex !== -1) {
playlistStartIndex++
if (channels[i].playlist.length == playlistStartIndex)
if (playlistStartIndex === channels[i].items.length)
playlistStartIndex = 0
} else {
playlistStartIndex = 0
}
// write programs from plex, starting at the live playlist index.
while (startingDate < endDate) {
for (var y = playlistStartIndex; y < channels[i].playlist.length; y++) {
for (var y = playlistStartIndex; y < channels[i].items.length && startingDate < endDate; y++) {
var stopDate = new Date(startingDate.valueOf())
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].playlist[y].duration)
var plexURL = channels[i].playlist[y].Media[0].Part[0].key
var optimizedForStreaming = false
for (var z = 0; z < channels[i].playlist[y].Media.length; z++) {
var part = channels[i].playlist[y].Media[z].Part[0]
if (typeof part.optimizedForStreaming !== 'undefined' && part.optimizedForStreaming) {
plexURL = part.key
optimizedForStreaming = part.optimizedForStreaming
break;
}
}
plexURL = `http://${config.PLEX_OPTIONS.hostname}:${config.PLEX_OPTIONS.port}${plexURL}?X-Plex-Token=${config.PLEX_OPTIONS.token}`
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].items[y].duration)
var program = {
info: channels[i].playlist[y],
info: channels[i].items[y],
channel: channels[i].channel,
start: new Date(startingDate.valueOf()),
stop: stopDate,
plexURL: plexURL,
optimizedForStreaming: optimizedForStreaming.toString()
stop: stopDate
}
writeProgramme(xw, program)
startingDate.setMilliseconds(startingDate.getMilliseconds() + channels[i].playlist[y].duration)
playlistStartIndex = 0
startingDate.setMilliseconds(startingDate.getMilliseconds() + channels[i].items[y].duration)
}
playlistStartIndex = 0
}
}
}
@ -192,23 +183,17 @@ function UpdateXMLTV(channels, cb) {
xw.endElement()
// End Doc
xw.endDocument()
fs.writeFileSync(config.XMLTV_OUTPUT, xw.toString())
fs.writeFileSync(config.XMLTV_FILE, xw.toString())
if (typeof cb == 'function')
cb()
}
module.exports = {
WriteXMLTV: WriteXMLTV,
UpdateXMLTV: UpdateXMLTV,
readXMLPrograms: readXMLPrograms
}
function writeChannels(xw, channels) {
// Channels
for (var i = 0; i < channels.length; i++) {
xw.startElement('channel')
xw.writeAttribute('id', channels[i].channel)
xw.writeAttribute('shuffle', channels[i].shuffle ? 'yes': 'no')
xw.startElement('display-name')
xw.writeAttribute('lang', 'en')
xw.text(channels[i].name)
@ -221,16 +206,14 @@ function writeChannels(xw, channels) {
xw.endElement()
}
}
function writeProgramme(xw, program) {
// Programme
xw.startElement('programme')
xw.writeAttribute('start', createXMLTVDate(program.start))
xw.writeAttribute('stop', createXMLTVDate(program.stop))
xw.writeAttribute('channel', program.channel)
// For VLC to handle...
xw.writeAttribute('video', program.plexURL)
xw.writeAttribute('optimized', program.optimizedForStreaming)
xw.writeAttribute('guid', program.info.guid)
xw.writeAttribute('plex-key', program.info.key) // Used to link this programme to Plex..
// Title
xw.startElement('title')
xw.writeAttribute('lang', 'en')
@ -255,9 +238,9 @@ function writeProgramme(xw, program) {
// Icon
xw.startElement('icon')
if (program.info.type == 'movie')
xw.writeAttribute('src', 'http://' + config.PLEX_OPTIONS.hostname + ':' + config.PLEX_OPTIONS.port + program.info.thumb + '?X-Plex-Token=' + config.PLEX_OPTIONS.token)
xw.writeAttribute('src', 'http://' + config.PLEX_OPTIONS.hostname + ':' + config.PLEX_OPTIONS.port + program.info.thumb)
else if (program.info.type == 'episode')
xw.writeAttribute('src', 'http://' + config.PLEX_OPTIONS.hostname + ':' + config.PLEX_OPTIONS.port + program.info.parentThumb + '?X-Plex-Token=' + config.PLEX_OPTIONS.token)
xw.writeAttribute('src', 'http://' + config.PLEX_OPTIONS.hostname + ':' + config.PLEX_OPTIONS.port + program.info.parentThumb)
xw.endElement()
// Desc
xw.startElement('desc')