v2 alpha
66
README.md
@ -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
|
||||
|
||||

|
||||
## 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.
|
||||
|
||||

|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
**Use the XMLTV option and select the pseudotv-plex generated xmltv.xml file**
|
||||
# PseudoTV Web UI
|
||||
|
||||

|
||||
Manually trigger EPG updates and view active channels using the Web UI.
|
||||
|
||||
Channels should automatically be matched. **Click continue**
|
||||
|
||||

|
||||

|
||||
BIN
docs/dvr1.png
|
Before Width: | Height: | Size: 17 KiB |
BIN
docs/dvr2.png
|
Before Width: | Height: | Size: 20 KiB |
BIN
docs/dvr3.png
|
Before Width: | Height: | Size: 16 KiB |
BIN
docs/dvr4.png
|
Before Width: | Height: | Size: 16 KiB |
BIN
docs/guide.png
|
Before Width: | Height: | Size: 490 KiB After Width: | Height: | Size: 513 KiB |
|
Before Width: | Height: | Size: 41 KiB |
BIN
docs/pseudotv.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/transcode.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
68
index.js
@ -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
@ -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",
|
||||
|
||||
10
package.json
@ -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
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
400
src/hdhr.js
@ -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&DEV_1040&SUBSYS_0001&REV_0004 VEN_0115&DEV_1040&SUBSYS_0001 VEN_0115&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
@ -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&DEV_1040&SUBSYS_0001&REV_0004 VEN_0115&DEV_1040&SUBSYS_0001 VEN_0115&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
@ -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
|
||||
@ -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)
|
||||
|
||||
83
src/plex.js
@ -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
@ -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
@ -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
@ -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
@ -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
|
||||
}
|
||||
188
src/vlc.js
@ -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`)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
169
src/xmltv.js
@ -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')
|
||||
|
||||