This commit is contained in:
Dan Ferguson 2020-04-23 16:11:40 -04:00
parent ff808b419e
commit 681ab5f6c6
50 changed files with 9465 additions and 1941 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
bin
dist
.pseudotv

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:12.16
WORKDIR /home/node/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm install -g browserify
RUN npm run build
RUN apt-get -y update
RUN apt-get -y upgrade
RUN apt-get install -y ffmpeg
EXPOSE 8000
CMD [ "npm", "start"]

110
README.md
View File

@ -1,81 +1,73 @@
# pseudotv-plex
Create Live TV/DVR channels from playlists in Plex.
Create your own Live TV channels from media on your Plex Server(s).
![DVR Guide](docs/guide.png)
Simply create your Channels, add the PseudoTV tuner to Plex, and enjoy your fake TV service.
### How it works
## How it works
1. pseudotv-plex will scan your Plex library for playlists, looking for playlists with a summary starting with **pseudotv**.
2. XMLTV and M3U files are generated from your **pseudotv** 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 pseudo live tv channels
FFMPEG is used to transcode media on the fly to MPEG2/AC3 mpegts streams (with constant bitrate, resolution, framerate). Cool thing about the MPEG2 codec and MPEGTS format is that files can be concatenated together without messing up the file structure. This allows PseudoTV to support continous playback and commercials without having Plex trip balls when a new video segment is hit.
### Features
## Features
- Plex transcoding (psuedotv-plex spoofs a Chrome Web Player, in order to receive a h264/aac stream from Plex)
- 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
- Web UI for channel configuration and app settings
- Select media across multiple Plex servers
- Ability to auto update Plex EPG and channel mappings
- Continuous playback support
- Commercial support
- Docker and prepackage binaries for Windows, Linux and Mac
**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..**
## Release Notes
- Channels are now created through the Web UI
- Plex Transcoding is disabled (media timeline updates are disabled too). If anybody can figure out how to get Plex to transcode to MPEG2, let me know.. If Plex could transcode to MPEG2/MPEGTS then we might not even need FFMPEG.
- Previous versions of pseudotv (I think it was the first build) had a bug where everytime the app was restarted, a new client ID was registered with Plex. Plex would fill up with authorized devices and in some case would crash Plex Server or cripple performance. Please check your authorized devices in Plex and clean up any PseudoTV duplicates. I'm sorry I didn't spot this sooner, this may be a headache cleaning up.
- Fixed the HDHR tuner count. You can now set the number of tuners availble to Plex.
## Prerequisites
## Installation
**So far only tested in Windows. Should work cross platform. Docker container support coming soon.**
Unless your are using the Docker image, you must download and install **ffmpeg** to your system and set the correct path in the Web UI.
Install [NodeJS](https://nodejs.org/), and either [VLC](https://www.videolan.org/vlc/) or [FFMPEG](https://www.ffmpeg.org/)
By default, pseudotv will create a directory (`.pseudotv`) where the app was lauched. Your xmltv.xml file and pseudotv databases are stored here.
## Install
**Do not use a URL when feeding Plex the xmltv.xml file, Plex fails to update it's EPG from a URL for some reason (at least on Windows)**
#### Binary Release
Download and run the PseudoTV executable (argument defaults below)
```
./pseudotv-win.exe --host 127.0.0.1 --port 8000 --database ./pseudotv --xmltv ./pseudotv/xmltv.xml
```
Use the WebUI to provide PseudoTV the path to FFMPEG
#### Docker Image
```
cd pseudotv-plex
docker build -t pseudotv .
docker run --name pseudotv -p 8000:8000 -v C:\.pseudotv:/home/node/app/.pseudotv pseudotv
```
#### Source
```
cd pseudotv-plex
npm install
npm run build
npm run start
```
## Configure
You must provide your Plex server details and the location of VLC or FFMPEG
### Edit the **`config.yml`** configuration file
## Start
## Development
Building Binaries:
```
npm start
cd pseudotv-plex
npm install
npm run build
npm run compile
npm run package
```
# Plex Playlist Setup
To assign a playlist as a channel, edit the summary of the playlist in Plex and write **pseudotv** at the beginning.
**optional parameters:** *channelNumber*, *iconURL* and/or *shuffle*. In any order..
If no channel number is specifed, the Plex playist's id/key is used.
## Plex Playlist Example
### Title
Live Development:
```
My Channel Name
```
### Summary
```
pseudotv 100 shuffle http://some.url/channel-icon.png
```
# Plex DVR Setup
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.
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.
# Plex Transcoding
When a channel is requested, pseudotv-plex will determine the current playing program and request a transcoded stream from Plex. When pseudotv-plex recieves the h264/acc stream,it is remuxed (using vlc or ffmpeg) into a mpegts container to be utilized by Plex DVR.
![DVR Guide](docs/transcode.png)
# PseudoTV Web UI
Manually trigger EPG updates and view active channels using the Web UI.
![DVR Guide](docs/pseudotv.png)
cd pseudotv-plex
npm run dev-client
npm run dev-server
```

View File

@ -1,58 +0,0 @@
#
# Project: pseudotv-plex
# Author: Dan Ferguson (dan.ferguson11@outlook.com)
# Repository: https://gitlab.com/DEFENDORe/pseudotv-plex
#
# Server Settings
HOST: 127.0.0.1 # PseudoTV Host
PORT: 8000 # PseudoTV Port
# PLEX SERVER DETAILS
PLEX_OPTIONS:
hostname: 127.0.0.1
port: 32400
# Since we're storing credentials, you might want to muck around with file
# permissions to prevent this file from being opened by anyone all willy nilly.
username: your-plex-username
password: your-plex-password
# Setup your muxer of choice. Either VLC or FFMPEG.
MUXER: ffmpeg
FFMPEG_OPTIONS:
PATH: C:\ffmpeg-20200328-3362330-win64-static\bin\ffmpeg.exe # path to ffmpeg
PREBUFFER: true # transmux entire program as fast as possible... Set false to stream "live" (ffmpeg -re ...)
VLC_OPTIONS:
PATH: C:\Program Files\VideoLAN\VLC\vlc.exe # path to vlc
PORT: 8001 # VLC will host a http server to serve mpegts stream
DELAY: 1000 # milliseconds - wait 1 second cause VLC can be a piece of shit
HIDDEN: true # hide the vlc session
# Spoofed HDHomeRun Tuner Settings
HDHOMERUN_OPTIONS:
AUTODISCOVERY: false # Enable the finicky as fuck ssdp broadcaster.
ENABLED: true # Disable the HDHR tuner altogether if using xTeVe
# Specify how much EPG information to store in the XMLTV file
EPG_CACHE: 12 # hrs
# Specify how often to refresh epg
# NOTE: "shuffle"d playlists will get reshuffled on every update, it'd be a good idea to increase this to prevent pissing off users...
EPG_UPDATE: 20 # mins (0 = disable updating)
# Plex Automagic (if you host separate Plex Servers for your LiveTV and Playlists, you should disable these..)
PLEX_AUTO_REFRESH_GUIDE: true # Refresh Plex DVR automatically
PLEX_AUTO_REMAP_CHANNELS: true # Map/Remap Plex DVR Channels automatically
# Generated Files...
XMLTV_FILE: ./xmltv.xml # the XMLTV file to import into Plex, or xTeVe
M3U_FILE: ./channels.m3u # a M3U file containing channel lineup. Useful for xTeVe or viewing with external player.
# The first word in a playlists summary must match this for the playlist to be deemed a channel.
PLEX_PLAYLIST_IDENTIFIER: pseudotv
# X-Plex-Session-Identifier Header Used for identifing transcoding session in Plex.
# Must be unique if running multiple instances of pseudotv-plex.
# Don't know how the fuck Plex generates them but they gotta be valid or else Plex will reject the request.
# This one was fetched with Chrome Dev Tools..
# DO NOT CHANGE UNLESS YOU KNOW WHATS UP... SHIT COULD/PROBABLY WILL BREAK.
PLEX_SESSION_ID: 6fooaniy59s3hqqhqyn21xti

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

151
index.js
View File

@ -1,37 +1,134 @@
const db = require('diskdb')
const fs = require('fs')
const path = require('path')
const express = require('express')
var config = require('config-yml')
const bodyParser = require('body-parser')
const api = require('./src/api')
const video = require('./src/video')
const HDHR = require('./src/hdhr')
const plex = require('./src/plex')
const xmltv = require('./src/xmltv')
const m3u = require('./src/m3u')
const ffmpeg = require('./src/ffmpeg')
const vlc = require('./src/vlc')
const hdhr = require('./src/hdhr')()
const pseudotv = require('./src/pseudotv')
const Plex = require('./src/plex')
plex(config.PLEX_OPTIONS, (result) => {
if (result.err)
return console.error("Failed to create plex client.", result.err)
var client = result.client
const helperFuncs = require('./src/helperFuncs')
console.log("Plex authentication successful")
for (let i = 0, l = process.argv.length; i < l; i++) {
if ((process.argv[i] === "-p" || process.argv[i] === "--port") && i + 1 !== l)
process.env.PORT = process.argv[i + 1]
if ((process.argv[i] === "-h" || process.argv[i] === "--host") && i + 1 !== l)
process.env.HOST = process.argv[i + 1]
if ((process.argv[i] === "-d" || process.argv[i] === "--database") && i + 1 !== l)
process.env.DATABASE = process.argv[i + 1]
if ((process.argv[i] === "-x" || process.argv[i] === "--xmltv") && i + 1 !== l)
process.env.XMLTV = process.argv[i + 1]
}
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")
process.env.DATABASE = process.env.DATABASE || './.pseudotv'
process.env.XMLTV = process.env.XMLTV || './.pseudotv/xmltv.xml'
process.env.PORT = process.env.PORT || 8000
process.env.HOST = process.env.HOST || "127.0.0.1"
if (config.HDHOMERUN_OPTIONS.ENABLED)
app.use(hdhr.router)
if (!fs.existsSync(process.env.DATABASE))
fs.mkdirSync(process.env.DATABASE)
app.use(pseudotv(client, xmltv, m3u))
db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'xmltv-settings', 'hdhr-settings'])
app.listen(config.PORT, () => {
console.log(`pseudotv-plex Web UI: http://${config.HOST}:${config.PORT}`)
if (config.HDHOMERUN_OPTIONS.ENABLED && config.HDHOMERUN_OPTIONS.AUTODISCOVERY)
hdhr.ssdp.start()
})
initDB(db)
let xmltvInterval = {
interval: null,
lastRefresh: null,
updateXML: () => {
let channels = db['channels'].find()
channels.sort((a, b) => { return a.number < b.number ? -1 : 1 })
let xmltvSettings = db['xmltv-settings'].find()[0]
xmltv.WriteXMLTV(channels, xmltvSettings).then(() => { // Update XML
xmltvInterval.lastRefresh = new Date()
console.log('XMLTV Updated at ', xmltvInterval.lastRefresh.toLocaleString())
let plexServers = db['plex-servers'].find()
for (let i = 0, l = plexServers.length; i < l; i++) { // Foreach plex server
let ips = helperFuncs.getIPAddresses()
for (let y = 0, l2 = ips.length; y < l2; y++) {
if (ips[y] === plexServers[i].host) {
plexServers[i].host = "127.0.0.1" // If the plex servers IP is the same as PseudoTV, just use the loopback cause for some reason PUT and POST requests will fail.
break
}
}
var plex = new Plex(plexServers[i])
plex.GetDVRS().then((dvrs) => { // Refresh guide and channel mappings
if (plexServers[i].arGuide)
plex.RefreshGuide(dvrs).then(() => { }, (err) => { console.error(err) })
if (plexServers[i].arChannels)
plex.RefreshChannels(channels, dvrs).then(() => { }, (err) => { console.error(err) })
})
}
}, (err) => {
console.error("Failed to write the xmltv.xml file. Check your output directory via the web UI and verify file permissions.")
})
},
startInterval: () => {
let xmltvSettings = db['xmltv-settings'].find()[0]
if (xmltvSettings.refresh !== 0) {
xmltvInterval.interval = setInterval(() => {
xmltvInterval.updateXML()
}, xmltvSettings.refresh * 60 * 60 * 1000)
}
},
restartInterval: () => {
if (xmltvInterval.interval !== null)
clearInterval(xmltvInterval.interval)
xmltvInterval.startInterval()
}
}
xmltvInterval.updateXML()
let hdhr = HDHR(db)
let app = express()
app.use(bodyParser.json())
app.use(express.static(__dirname + '/web/public'))
app.use(api.router(db, xmltvInterval))
app.use(video.router(db))
app.use(hdhr.router)
app.listen(process.env.PORT, () => {
console.log(`HTTP server running on port: http://${process.env.HOST}:${process.env.PORT}`)
let hdhrSettings = db['hdhr-settings'].find()[0]
if (hdhrSettings.autoDiscovery === true)
hdhr.ssdp.start()
})
function initDB(db) {
let ffmpegSettings = db['ffmpeg-settings'].find()
if (ffmpegSettings.length === 0) {
db['ffmpeg-settings'].save({
ffmpegPath: "/usr/bin/ffmpeg",
offset: 0,
threads: '4',
videoEncoder: 'mpeg2video',
videoResolution: '1280x720',
videoFrameRate: '30',
videoBitrate: '10000k',
audioBitrate: '192k',
audioChannels: '2',
audioRate: '48000',
bufSize: '1000k',
audioEncoder: 'ac3'
})
}
let xmltvSettings = db['xmltv-settings'].find()
if (xmltvSettings.length === 0) {
db['xmltv-settings'].save({
cache: 12,
refresh: 4,
file: process.env.XMLTV
})
}
let hdhrSettings = db['hdhr-settings'].find()
if (hdhrSettings.length === 0) {
db['hdhr-settings'].save({
tunerCount: 1,
autoDiscovery: true
})
}
}

6724
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +1,49 @@
{
"name": "pseudotv-plex",
"name": "pseudotv",
"version": "1.0.0",
"description": "Create Live TV/DVR channels from playlists in Plex.",
"description": "Create LiveTV channels from your Plex media",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node index.js",
"dev": "nodemon index.js -e js,json,yml"
"build": "browserify ./web/app.js -o ./web/public/bundle.js",
"dev-client": "watchify ./web/app.js -o ./web/public/bundle.js",
"dev-server": "nodemon index.js --ignore ./web/ --ignore ./db/ --ignore ./xmltv.xml",
"compile": "babel index.js -d dist && babel src -d dist/src",
"package": "copyfiles ./web/public/**/* ./dist && pkg . --out-path bin",
"clean": "del-cli --force ./bin ./dist ./.pseudotv ./web/public/bundle.js"
},
"author": "Dan Ferguson",
"license": "ISC",
"dependencies": {
"config-yml": "^0.10.3",
"angular": "^1.7.9",
"angular-router-browserify": "0.0.2",
"body-parser": "^1.19.0",
"diskdb": "^0.1.17",
"express": "^4.17.1",
"node-ssdp": "^4.0.0",
"query-string": "^6.11.1",
"request": "^2.88.2",
"xml-reader": "^2.4.3",
"xml-writer": "^1.7.0",
"xml2json": "^0.12.0"
"xml-writer": "^1.7.0"
},
"bin": "dist/index.js",
"pkg": {
"assets": "dist/web/public/**/*"
},
"devDependencies": {
"@babel/cli": "^7.8.4",
"@babel/core": "^7.9.0",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/preset-env": "^7.9.5",
"browserify": "^16.5.1",
"copyfiles": "^2.2.0",
"del-cli": "^3.0.0",
"nodemon": "^2.0.3",
"pkg": "^4.4.7",
"watchify": "^3.11.1"
},
"babel": {
"plugins": [
"@babel/plugin-proposal-class-properties"
]
}
}

155
src/api.js Normal file
View File

@ -0,0 +1,155 @@
const express = require('express')
const fs = require('fs')
module.exports = { router: api }
function api(db, xmltvInterval) {
let router = express.Router()
// Plex Servers
router.get('/api/plex-servers', (req, res) => {
let servers = db['plex-servers'].find()
res.send(servers)
})
router.delete('/api/plex-servers', (req, res) => {
db['plex-servers'].remove(req.body, false)
let servers = db['plex-servers'].find()
res.send(servers)
})
router.post('/api/plex-servers', (req, res) => {
db['plex-servers'].save(req.body)
let servers = db['plex-servers'].find()
res.send(servers)
})
// Channels
router.get('/api/channels', (req, res) => {
let channels = db['channels'].find()
channels.sort((a, b) => { return a.number < b.number ? -1 : 1 })
res.send(channels)
})
router.post('/api/channels', (req, res) => {
db['channels'].save(req.body)
let channels = db['channels'].find()
channels.sort((a, b) => { return a.number < b.number ? -1 : 1 })
res.send(channels)
updateXmltv()
})
router.put('/api/channels', (req, res) => {
db['channels'].update({ _id: req.body._id }, req.body)
let channels = db['channels'].find()
channels.sort((a, b) => { return a.number < b.number ? -1 : 1 })
res.send(channels)
updateXmltv()
})
router.delete('/api/channels', (req, res) => {
db['channels'].remove({ _id: req.body._id }, false)
let channels = db['channels'].find()
channels.sort((a, b) => { return a.number < b.number ? -1 : 1 })
res.send(channels)
updateXmltv()
})
// FFMPEG SETTINGS
router.get('/api/ffmpeg-settings', (req, res) => {
let ffmpeg = db['ffmpeg-settings'].find()[0]
res.send(ffmpeg)
})
router.put('/api/ffmpeg-settings', (req, res) => {
db['ffmpeg-settings'].update({ _id: req.body._id }, req.body)
let ffmpeg = db['ffmpeg-settings'].find()[0]
res.send(ffmpeg)
})
router.post('/api/ffmpeg-settings', (req, res) => { // RESET
db['ffmpeg-settings'].update({ _id: req.body._id }, {
ffmpegPath: req.body.ffmpegPath,
offset: 0,
threads: '4',
videoEncoder: 'mpeg2video',
videoResolution: '1280x720',
videoFrameRate: '30',
videoBitrate: '10000k',
audioBitrate: '192k',
audioChannels: '2',
audioRate: '48000',
bufSize: '1000k',
audioEncoder: 'ac3'
})
let ffmpeg = db['ffmpeg-settings'].find()[0]
res.send(ffmpeg)
})
router.get('/api/xmltv-last-refresh', (req, res) => {
res.send(JSON.stringify({ value: xmltvInterval.lastUpdated.valueOf() }))
})
// XMLTV SETTINGS
router.get('/api/xmltv-settings', (req, res) => {
let xmltv = db['xmltv-settings'].find()[0]
res.send(xmltv)
})
router.put('/api/xmltv-settings', (req, res) => {
db['xmltv-settings'].update({ _id: req.body._id }, req.body)
let xmltv = db['xmltv-settings'].find()[0]
res.send(xmltv)
updateXmltv()
})
router.post('/api/xmltv-settings', (req, res) => {
db['xmltv-settings'].update({ _id: req.body._id }, {
_id: req.body._id,
cache: 12,
refresh: 4,
file: process.env.XMLTV
})
var xmltv = db['xmltv-settings'].find()[0]
res.send(xmltv)
updateXmltv()
})
//HDHR SETTINGS
router.get('/api/hdhr-settings', (req, res) => {
let hdhr = db['hdhr-settings'].find()[0]
res.send(hdhr)
})
router.put('/api/hdhr-settings', (req, res) => {
db['hdhr-settings'].update({ _id: req.body._id }, req.body)
let hdhr = db['hdhr-settings'].find()[0]
res.send(hdhr)
})
router.post('/api/hdhr-settings', (req, res) => {
db['hdhr-settings'].update({ _id: req.body._id }, {
_id: req.body._id,
tunerCount: 1,
autoDiscovery: true,
})
var hdhr = db['hdhr-settings'].find()[0]
res.send(hdhr)
})
// XMLTV.XML Download
router.get('/api/xmltv.xml', (req, res) => {
res.type('text')
let xmltvSettings = db['xmltv-settings'].find()[0]
res.send(fs.readFileSync(xmltvSettings.file))
})
router.get('/api/channels.m3u', (req, res) => {
res.type('text')
let channels = db['channels'].find()
var data = "#EXTM3U\n"
for (var i = 0; i < channels.length; i++) {
data += `#EXTINF:0 tvg-id="${channels[i].number}" tvg-name="${channels[i].name}" tvg-logo="${channels[i].icon}",${channels[i].number}\n`
data += `http://${process.env.HOST}:${process.env.PORT}/video?channel=${channels[i].number}\n`
}
res.send(data)
})
function updateXmltv() {
xmltvInterval.updateXML()
xmltvInterval.restartInterval()
}
return router
}

View File

@ -1,134 +1,53 @@
const Router = require('express').Router
const spawn = require('child_process').spawn
const config = require('config-yml')
const xmltv = require('./xmltv')
var events = require('events')
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'
class ffmpeg extends events.EventEmitter {
constructor(opts) {
super()
this.offset = 0
this.opts = opts
}
spawn(lineupItem) {
let args = [
'-threads', this.opts.threads,
'-ss', lineupItem.start / 1000,
'-t', lineupItem.duration / 1000,
'-re',
'-i', lineupItem.file,
'-c:v', this.opts.videoEncoder,
'-c:a', this.opts.audioEncoder,
'-ac', this.opts.audioChannels,
'-ar', this.opts.audioRate,
'-b:a', this.opts.audioBitrate,
'-b:v', this.opts.videoBitrate,
'-s', this.opts.videoResolution,
'-r', this.opts.videoFrameRate,
'-flags', 'cgop+ilme', // Dont know if this does fuck all
'-sc_threshold', '1000000000', // same here...
'-minrate:v', this.opts.videoBitrate,
'-maxrate:v', this.opts.videoBitrate,
'-bufsize:v', this.opts.bufSize,
'-f', 'mpegts',
'-output_ts_offset', this.offset, // This actually helped.. VLC still shows "TS discontinuity" errors tho..
'pipe:1'
]
this.offset += lineupItem.duration / 1000
this.ffmpeg = spawn(this.opts.ffmpegPath, args)
this.ffmpeg.stdout.on('data', (chunk) => {
this.emit('data', chunk)
})
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()
}
})
})
this.ffmpeg.on('close', (code) => {
if (code === null)
this.emit('close', code)
else if (code === 0)
this.emit('end')
else
this.emit('error', { code: code, cmd: `${args.join(' ')}` })
})
}
}
kill() {
this.ffmpeg.kill()
}
}
module.exports = ffmpeg

94
src/hdhr.js Normal file
View File

@ -0,0 +1,94 @@
const express = require('express')
const SSDP = require('node-ssdp').Server
module.exports = hdhr
function hdhr(db) {
const server = new SSDP({
location: {
port: process.env.PORT,
path: '/device.xml'
},
udn: `uuid:2020-03-S3LA-BG3LIA:2`,
allowWildcards: true,
ssdpSig: 'PsuedoTV/0.1 UPnP/1.0'
})
server.addUSN('upnp:rootdevice')
server.addUSN('urn:schemas-upnp-org:device:MediaServer:1')
var router = express.Router()
router.get('/device.xml', (req, res) => {
var device = getDevice(db)
res.header("Content-Type", "application/xml")
var data = device.getXml()
res.send(data)
})
router.get('/discover.json', (req, res) => {
var device = getDevice(db)
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 lineup = []
var channels = db['channels'].find()
for (let i = 0, l = channels.length; i < l; i++)
lineup.push({ GuideNumber: channels[i].number.toString(), GuideName: channels[i].name, URL: `http://${process.env.HOST}:${process.env.PORT}/video?channel=${channels[i].number}` })
res.send(JSON.stringify(lineup))
})
return { router: router, ssdp: server }
}
function getDevice(db) {
let hdhrSettings = db['hdhr-settings'].find()[0]
var device = {
FriendlyName: "PseudoTV",
Manufacturer: "PseudoTV - Silicondust",
ManufacturerURL: "https://gitlab.org/DEFENDORe/pseudotv-plex",
ModelNumber: "HDTC-2US",
FirmwareName: "hdhomeruntc_atsc",
TunerCount: hdhrSettings.tunerCount,
FirmwareVersion: "20170930",
DeviceID: 'PseudoTV',
DeviceAuth: "",
BaseURL: `http://${process.env.HOST}:${process.env.PORT}`,
LineupURL: `http://${process.env.HOST}:${process.env.PORT}/lineup.json`
}
device.getXml = () => {
str =
`<root xmlns="urn:schemas-upnp-org:device-1-0">
<URLBase>${device.BaseURL}</URLBase>
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<friendlyName>PseudoTV</friendlyName>
<manufacturer>Silicondust</manufacturer>
<modelName>HDTC-2US</modelName>
<modelNumber>HDTC-2US</modelNumber>
<serialNumber/>
<UDN>uuid:2020-03-S3LA-BG3LIA:2</UDN>
</device>
</root>`
return str
}
return device
}

View File

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

View File

@ -1,57 +0,0 @@
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

106
src/helperFuncs.js Normal file
View File

@ -0,0 +1,106 @@
const os = require('os')
module.exports = {
getLineup: getLineup,
getCurrentProgramAndTimeElapsed: getCurrentProgramAndTimeElapsed,
getIPAddresses: getIPAddresses
}
function getIPAddresses() {
var ifaces = os.networkInterfaces();
var addresses = []
Object.keys(ifaces).forEach(function (ifname) {
ifaces[ifname].forEach(function (iface) {
if ('IPv4' !== iface.family || iface.internal !== false) {
return
}
addresses.push(iface.address)
})
})
return addresses
}
function getLineup(date, channel) {
let _obj = getCurrentProgramAndTimeElapsed(date, channel)
let lineup = createProgramStreamTimeline(_obj)
return lineup
}
function getCurrentProgramAndTimeElapsed(date, channel) {
let channelStartTime = new Date(channel.startTime)
if (channelStartTime > date)
throw new Error("startTime cannot be set in the future. something fucked up..")
let timeElapsed = (date.valueOf() - channelStartTime.valueOf()) % channel.duration
let currentProgramIndex = -1
for (let y = 0, l2 = channel.programs.length; y < l2; y++) {
let program = channel.programs[y]
if (timeElapsed - program.duration < 0) {
currentProgramIndex = y
break
} else {
timeElapsed -= program.duration
}
}
if (currentProgramIndex === -1)
throw new Error("No program found; find algorithm fucked up")
return { program: channel.programs[currentProgramIndex], timeElapsed: timeElapsed, programIndex: currentProgramIndex }
}
function createProgramStreamTimeline(obj) {
let timeElapsed = obj.timeElapsed
let activeProgram = obj.program
let lineup = []
let programStartTimes = [0, activeProgram.actualDuration * .25, activeProgram.actualDuration * .50, activeProgram.actualDuration * .75, activeProgram.actualDuration]
let commercials = [[], [], [], [], []]
for (let i = 0, l = activeProgram.commercials.length; i < l; i++) // Sort the commercials into their own commerical "slot" array
commercials[activeProgram.commercials[i].commercialPosition].push(activeProgram.commercials[i])
let foundFirstVideo = false
for (let i = 0, l = commercials.length; i < l; i++) { // Foreach commercial slot
for (let y = 0, l2 = commercials[i].length; y < l2; y++) { // Foreach commercial in that slot
if (!foundFirstVideo && timeElapsed - commercials[i][y].duration < 0) { // If havent already found the starting video AND the this is a the starting video
foundFirstVideo = true // We found the fucker
lineup.push({
type: 'commercial',
file: commercials[i][y].file,
start: timeElapsed, // start time will be the time elapsed, cause this is the first video
duration: commercials[i][y].duration - timeElapsed // duration set accordingly
})
} else if (foundFirstVideo) { // Otherwise, if weve already found the starting video
lineup.push({ // just add the video, starting at 0, playing the entire duration
type: 'commercial',
file: commercials[i][y].file,
start: 0,
duration: commercials[i][y].duration
})
} else { // Otherwise, this bitch has already been played.. Reduce the time elapsed by its duration
timeElapsed -= commercials[i][y].duration
}
}
if (i !== l - 1) { // The last commercial slot is END, so dont write a program..
if (!foundFirstVideo && timeElapsed - (programStartTimes[i + 1] - programStartTimes[i]) < 0) { // same shit as above..
foundFirstVideo = true
lineup.push({
type: 'program',
file: activeProgram.file,
start: timeElapsed,
duration: (programStartTimes[i + 1] - programStartTimes[i]) - timeElapsed
})
} else if (foundFirstVideo) {
if (lineup[lineup.length - 1].type === 'program') { // merge consecutive programs..
lineup[lineup.length - 1].duration += (programStartTimes[i + 1] - programStartTimes[i])
} else {
lineup.push({
type: 'program',
file: activeProgram.file,
start: programStartTimes[i],
duration: (programStartTimes[i + 1] - programStartTimes[i])
})
}
} else {
timeElapsed -= (programStartTimes[i + 1] - programStartTimes[i])
}
}
}
return lineup
}

View File

@ -1,31 +0,0 @@
const fs = require('fs')
const config = require('config-yml')
function WriteM3U(channels, cb) {
var data = "#EXTM3U\n"
for (var i = 0; i < channels.length; i++) {
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_FILE, data)
if (typeof cb == 'function')
cb()
}
// Formatted for HDHR lineup..
function ReadChannels() {
var m3uData = fs.readFileSync(config.M3U_FILE)
var track = m3uData.toString().split(/[\n]+/)
var channels = []
track.splice(0, 1)
track.pop()
for (var i = 0; i < track.length; i += 2) {
var tmp = track[i].split("\"")
channels.push({ GuideNumber: tmp[1], GuideName: tmp[3], URL: track[i + 1] })
}
return channels
}
module.exports = {
WriteM3U: WriteM3U,
ReadChannels: ReadChannels
}

139
src/plex.js Normal file
View File

@ -0,0 +1,139 @@
const request = require('request')
class Plex {
constructor(opts) {
this._token = typeof opts.token !== 'undefined' ? opts.token : ''
this._server = {
host: typeof opts.host !== 'undefined' ? opts.host : '127.0.0.1',
port: typeof opts.port !== 'undefined' ? opts.port : '32400',
protocol: typeof opts.protocol !== 'undefined' ? opts.protocol : 'http'
}
this._headers = {
'Accept': 'application/json',
'X-Plex-Device': 'PseudoTV',
'X-Plex-Device-Name': 'PseudoTV',
'X-Plex-Product': 'PseudoTV',
'X-Plex-Version': '0.1',
'X-Plex-Client-Identifier': 'rg14zekk3pa5zp4safjwaa8z',
'X-Plex-Platform': 'Chrome',
'X-Plex-Platform-Version': '80.0'
}
}
get URL() { return `${this._server.protocol}://${this._server.host}:${this._server.port}` }
SignIn(username, password) {
return new Promise((resolve, reject) => {
if (typeof username === 'undefined' || typeof password === 'undefined')
reject("Plex 'SignIn' Error - No Username or Password was provided to sign in.")
var req = {
method: 'post',
url: 'https://plex.tv/users/sign_in.json',
headers: this._headers,
form: {
user: {
login: username,
password: password
}
}
}
request(req, (err, res, body) => {
if (err || res.statusCode !== 201)
reject("Plex 'SignIn' Error - Username/Email and Password is incorrect!.")
else {
this._token = JSON.parse(body).user.authToken
resolve({ token: this._token })
}
})
})
}
Get(path, optionalHeaders = {}) {
var req = {
method: 'get',
url: `${this.URL}${path}`,
headers: this._headers
}
Object.assign(req, optionalHeaders)
req.headers['X-Plex-Token'] = this._token
return new Promise((resolve, reject) => {
if (this._token === '')
reject("No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.")
else
request(req, (err, res) => {
if (err || res.statusCode !== 200)
reject(`Plex 'Get' request failed. URL: ${this.URL}${path}`)
else
resolve(JSON.parse(res.body).MediaContainer)
})
})
}
Put(path, query = {}, optionalHeaders = {}) {
var req = {
method: 'put',
url: `${this.URL}${path}`,
headers: this._headers,
qs: query
}
Object.assign(req, optionalHeaders)
req.headers['X-Plex-Token'] = this._token
return new Promise((resolve, reject) => {
if (this._token === '')
reject("No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.")
else
request(req, (err, res) => {
if (err || res.statusCode !== 200)
reject(`Plex 'Put' request failed. URL: ${this.URL}${path}`)
else
resolve(res.body)
})
})
}
Post(path, query = {}, optionalHeaders = {}) {
var req = {
method: 'post',
url: `${this.URL}${path}`,
headers: this._headers,
qs: query
}
Object.assign(req, optionalHeaders)
req.headers['X-Plex-Token'] = this._token
return new Promise((resolve, reject) => {
if (this._token === '')
reject("No Plex token provided. Please use the SignIn method or provide a X-Plex-Token in the Plex constructor.")
else
request(req, (err, res) => {
if (err || res.statusCode !== 200)
reject(`Plex 'Post' request failed. URL: ${this.URL}${path}`)
else
resolve(res.body)
})
})
}
GetDVRS = async function () {
var result = await this.Get('/livetv/dvrs')
var dvrs = result.Dvr
dvrs = typeof dvrs === 'undefined' ? [] : dvrs
return dvrs
}
RefreshGuide = async function (_dvrs) {
var dvrs = typeof _dvrs !== 'undefined' ? _dvrs : await this.GetDVRS()
for (var i = 0; i < dvrs.length; i++)
this.Post(`/livetv/dvrs/${dvrs[i].key}/reloadGuide`).then(() => { }, (err) => { console.log(err) })
}
RefreshChannels = async function (channels, _dvrs) {
var dvrs = typeof _dvrs !== 'undefined' ? _dvrs : await this.GetDVRS()
var _channels = []
let qs = {}
for (var i = 0; i < channels.length; i++)
_channels.push(channels[i].number)
qs.channelsEnabled = _channels.join(',')
for (var i = 0; i < _channels.length; i++) {
qs[`channelMapping[${_channels[i]}]`] = _channels[i]
qs[`channelMappingByKey[${_channels[i]}]`] = _channels[i]
}
for (var i = 0; i < dvrs.length; i++)
for (var y = 0; y < dvrs[i].Device.length; y++)
this.Put(`/media/grabbers/devices/${dvrs[i].Device[y].key}/channelmap`, qs).then(() => { }, (err) => { console.log(err) })
}
}
module.exports = Plex

View File

@ -1,96 +0,0 @@
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
}

View File

@ -1,159 +0,0 @@
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

View File

@ -1,249 +0,0 @@
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
}
}

View File

@ -1,251 +0,0 @@
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
}

58
src/video.js Normal file
View File

@ -0,0 +1,58 @@
const express = require('express')
const helperFuncs = require('./helperFuncs')
const ffmpeg = require('./ffmpeg')
const fs = require('fs')
module.exports = { router: video }
function video(db) {
var router = express.Router()
router.get('/video', (req, res) => {
// Check if channel queried is valid
if (typeof req.query.channel === 'undefined') {
res.status(500).send("No Channel Specified")
return
}
let channel = db['channels'].find({ number: parseInt(req.query.channel, 10) })
if (channel.length === 0) {
res.status(500).send("Channel doesn't exist")
return
}
channel = channel[0]
// Get video lineup (array of video urls with calculated start times and durations.)
let lineup = helperFuncs.getLineup(Date.now(), channel)
let ffmpegSettings = db['ffmpeg-settings'].find()[0]
// Check if ffmpeg path is valid
if (!fs.existsSync(ffmpegSettings.ffmpegPath)) {
res.status(500).send("FFMPEG path is invalid. The file (executable) doesn't exist.")
console.error("The FFMPEG Path is invalid. Please check your configuration.")
return
}
console.log(`Stream started. Channel: ${channel.number} (${channel.name})`)
let ffmpeg2 = new ffmpeg(ffmpegSettings) // Set the transcoder options
ffmpeg2.on('data', (data) => { res.write(data) })
ffmpeg2.on('error', (err) => { console.error("FFMPEG ERROR", err) })
ffmpeg2.on('end', () => { // On finish transcode - END of program or commercial...
if (lineup.length === 0) // refresh the expired program/lineup
lineup = helperFuncs.getLineup(Date.now(), channel)
ffmpeg2.spawn(lineup.shift()) // Spawn the next ffmpeg process
})
res.on('close', () => { // on HTTP close, kill ffmpeg
ffmpeg2.kill()
console.log(`Stream ended. Channel: ${channel.number} (${channel.name})`)
})
ffmpeg2.spawn(lineup.shift()) // Spawn the ffmpeg process, fire this bitch up
})
return router
}

View File

@ -1,147 +0,0 @@
const Router = require('express').Router
const spawn = require('child_process').spawn
const request = require('request')
const config = require('config-yml')
const xmltv = require('./xmltv')
module.exports = vlcRouter
function vlcRouter(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()
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)
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)
vlc.on('close', (code) => {
clearInterval(timelineInterval)
stream.stop()
if (code !== 0 && !res.headersSent) {
res.status(400).send(`Error: VLC closed unexpectedly`)
}
})
})
})
}
}

View File

@ -1,199 +1,39 @@
const XMLWriter = require('xml-writer')
const XMLReader = require('xml-reader')
const fs = require('fs')
const config = require('config-yml')
const helperFuncs = require('./helperFuncs')
module.exports = {
WriteXMLTV: WriteXMLTV,
UpdateXMLTV: UpdateXMLTV,
readXMLPrograms: readXMLPrograms,
readXMLChannels: readXMLChannels
module.exports = { WriteXMLTV: WriteXMLTV }
function WriteXMLTV(channels, xmlSettings) {
return new Promise((resolve, reject) => {
let date = new Date()
var ws = fs.createWriteStream(xmlSettings.file)
var xw = new XMLWriter(true, (str, enc) => ws.write(str, enc))
ws.on('close', () => { resolve() })
ws.on('error', (err) => { reject(err) })
_writeDocStart(xw)
_writeChannels(xw, channels)
for (var i = 0; i < channels.length; i++)
_writePrograms(xw, channels[i], date, xmlSettings.cache)
_writeDocEnd(xw, ws)
ws.close()
})
}
function readXMLPrograms() {
var data = fs.readFileSync(config.XMLTV_FILE)
var xmltv = XMLReader.parseSync(data.toString())
var programs = []
var tv = xmltv.children
for (var i = 0; i < tv.length; i++) {
if (tv[i].name == 'channel')
continue;
var program = {
channel: tv[i].attributes.channel,
start: createDate(tv[i].attributes.start),
stop: createDate(tv[i].attributes.stop),
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 time = new Date()
// Build XMLTV and M3U files
xw.startDocument()
// Root TV Element
xw.startElement('tv')
xw.writeAttribute('generator-info-name', 'psuedotv-plex')
writeChannels(xw, channels)
// 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())
// 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].items[y].duration)
var program = {
info: channels[i].items[y],
channel: channels[i].channel,
start: new Date(tempDate.valueOf()),
stop: stopDate
}
writeProgramme(xw, program)
tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].items[y].duration)
}
}
}
// End TV
xw.endElement()
xw.endDocument()
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_FILE)
var xml = XMLReader.parseSync(data.toString())
var time = new Date()
function _writeDocStart(xw) {
xw.startDocument()
xw.startElement('tv')
xw.writeAttribute('generator-info-name', 'psuedotv-plex')
writeChannels(xw, channels)
// Foreach channel
for (var i = 0; i < channels.length; i++) {
// get non-expired programmes for channel
var validPrograms = []
for (var y = 0; y < xml.children.length; y++) {
if (xml.children[y].name == 'programme' && xml.children[y].attributes.channel == channels[i].channel) {
var showStop = createDate(xml.children[y].attributes.stop)
if (showStop > time)
validPrograms.push(xml.children[y])
}
}
// If Channel doesnt exists..
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].items.length && tempDate < future; y++) { // foreach item in playlist
var stopDate = new Date(tempDate.valueOf())
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].items[y].duration)
var program = {
info: channels[i].items[y],
channel: channels[i].channel,
start: new Date(tempDate.valueOf()),
stop: stopDate
}
writeProgramme(xw, program)
tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].items[y].duration)
}
}
} 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].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),
info: channels[i].items[z]
}
startingDate = new Date(program.stop.valueOf())
writeProgramme(xw, program)
break;
}
}
if (playlistStartIndex !== -1) {
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].items.length && startingDate < endDate; y++) {
var stopDate = new Date(startingDate.valueOf())
stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].items[y].duration)
var program = {
info: channels[i].items[y],
channel: channels[i].channel,
start: new Date(startingDate.valueOf()),
stop: stopDate
}
writeProgramme(xw, program)
startingDate.setMilliseconds(startingDate.getMilliseconds() + channels[i].items[y].duration)
}
playlistStartIndex = 0
}
}
}
// End TV
}
function _writeDocEnd(xw, ws) {
xw.endElement()
// End Doc
xw.endDocument()
fs.writeFileSync(config.XMLTV_FILE, xw.toString())
if (typeof cb == 'function')
cb()
}
function writeChannels(xw, channels) {
// Channels
function _writeChannels(xw, 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.writeAttribute('id', channels[i].number)
xw.startElement('display-name')
xw.writeAttribute('lang', 'en')
xw.text(channels[i].name)
@ -207,60 +47,76 @@ function writeChannels(xw, channels) {
}
}
function writeProgramme(xw, program) {
function _writePrograms(xw, channel, date, cache) {
let prog = helperFuncs.getCurrentProgramAndTimeElapsed(date, channel)
let cutoff = new Date((date.valueOf() - prog.timeElapsed) + (cache * 60 * 60 * 1000))
let temp = new Date(date.valueOf() - prog.timeElapsed)
if (channel.programs.length === 0)
return
let i = prog.programIndex
for (; temp < cutoff;) {
let program = {
program: channel.programs[i],
channel: channel.number,
start: new Date(temp.valueOf()),
stop: new Date(temp.valueOf() + channel.programs[i].duration)
}
_writeProgramme(xw, program)
temp.setMilliseconds(temp.getMilliseconds() + channel.programs[i].duration)
i++
if (i >= channel.programs.length)
i = 0
}
}
function _writeProgramme(xw, program) {
// Programme
xw.startElement('programme')
xw.writeAttribute('start', createXMLTVDate(program.start))
xw.writeAttribute('stop', createXMLTVDate(program.stop))
xw.writeAttribute('start', _createXMLTVDate(program.start))
xw.writeAttribute('stop', _createXMLTVDate(program.stop))
xw.writeAttribute('channel', program.channel)
xw.writeAttribute('plex-key', program.info.key) // Used to link this programme to Plex..
// Title
xw.startElement('title')
xw.writeAttribute('lang', 'en')
if (program.info.type == 'episode')
xw.text(program.info.grandparentTitle)
else
xw.text(program.info.title)
xw.endElement()
if (program.info.type == 'episode') {
if (program.program.type == 'episode') {
xw.text(program.program.showTitle)
xw.endElement()
xw.writeRaw('\n <previously-shown/>')
// Sub-Title
xw.startElement('sub-title')
xw.writeAttribute('lang', 'en')
xw.text(program.info.title)
xw.text(program.program.title)
xw.endElement()
// Episode-Number
xw.startElement('episode-num')
xw.writeAttribute('system', 'xmltv_ns')
xw.text((program.info.parentIndex - 1) + ' . ' + (program.info.index - 1) + ' . 0/1')
xw.text((program.program.season - 1) + ' . ' + (program.program.episode - 1) + ' . 0/1')
xw.endElement()
} else {
xw.text(program.program.title)
xw.endElement()
}
// Icon
xw.startElement('icon')
if (program.info.type == 'movie')
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)
xw.writeAttribute('src', program.program.icon)
xw.endElement()
// Desc
xw.startElement('desc')
xw.writeAttribute('lang', 'en')
xw.text(program.info.summary)
xw.text(program.program.summary)
xw.endElement()
// Date
if (typeof program.info.originallyAvailableAt !== 'undefined')
xw.writeElement('date', program.info.originallyAvailableAt.split('-').join(''))
// Rating
if (typeof program.info.contentRating != 'undefined') {
if (typeof program.program.rating !== 'undefined') {
xw.startElement('rating')
xw.writeAttribute('system', 'MPAA')
xw.writeElement('value', program.info.contentRating)
xw.writeElement('value', program.program.rating)
xw.endElement()
}
// End of Programme
xw.endElement()
}
function createXMLTVDate(d) {
function _createXMLTVDate(d) {
function pad(n) { return n < 10 ? '0' + n : n }
var timezone = d.toString().split('GMT')
timezone = timezone[timezone.length - 1].split(' ')[0]
@ -270,14 +126,4 @@ function createXMLTVDate(d) {
+ pad(d.getHours()) + ""
+ pad(d.getMinutes()) + ""
+ pad(d.getSeconds()) + " " + timezone
}
function createDate(xmlDate) {
var year = xmlDate.substr(0, 4)
var month = xmlDate.substr(4, 2) - 1
var day = xmlDate.substr(6, 2)
var hour = xmlDate.substr(8, 2)
var min = xmlDate.substr(10, 2)
var sec = xmlDate.substr(12, 2)
var date = new Date(year, month, day, hour, min, sec) // fuck the timezone.. It'll be the same as a new Date()...
return date
}

35
web/app.js Normal file
View File

@ -0,0 +1,35 @@
const angular = require('angular')
require('angular-router-browserify')(angular)
require('./ext/lazyload')(angular)
require('./ext/dragdrop')
var app = angular.module('myApp', ['ngRoute', 'angularLazyImg', 'dndLists'])
app.service('plex', require('./services/plex'))
app.service('pseudotv', require('./services/pseudotv'))
app.directive('plexSettings', require('./directives/plex-settings'))
app.directive('ffmpegSettings', require('./directives/ffmpeg-settings'))
app.directive('xmltvSettings', require('./directives/xmltv-settings'))
app.directive('hdhrSettings', require('./directives/hdhr-settings'))
app.directive('plexLibrary', require('./directives/plex-library'))
app.directive('programConfig', require('./directives/program-config'))
app.directive('channelConfig', require('./directives/channel-config'))
app.controller('settingsCtrl', require('./controllers/settings'))
app.controller('channelsCtrl', require('./controllers/channels'))
app.config(function ($routeProvider) {
$routeProvider
.when("/settings", {
templateUrl: "views/settings.html",
controller: 'settingsCtrl'
})
.when("/channels", {
templateUrl: "views/channels.html",
controller: 'channelsCtrl'
})
.otherwise({
redirectTo: "channels"
})
})

View File

@ -0,0 +1,41 @@
module.exports = function ($scope, pseudotv) {
$scope.channels = []
$scope.showChannelConfig = false
$scope.selectedChannel = null
$scope.selectedChannelIndex = -1
pseudotv.getChannels().then((channels) => {
$scope.channels = channels
})
$scope.removeChannel = (channel) => {
pseudotv.removeChannel(channel).then((channels) => {
$scope.channels = channels
})
}
$scope.onChannelConfigDone = (channel) => {
if (typeof channel !== 'undefined') {
if ($scope.selectedChannelIndex == -1) { // add new channel
pseudotv.addChannel(channel).then((channels) => {
$scope.channels = channels
})
} else { // update existing channel
pseudotv.updateChannel(channel).then((channels) => {
$scope.channels = channels
})
}
}
$scope.showChannelConfig = false
}
$scope.selectChannel = (index) => {
if (index === -1) {
$scope.selectedChannel = null
$scope.selectedChannelIndex = -1
} else {
let newObj = JSON.parse(angular.toJson($scope.channels[index]))
newObj.startTime = new Date(newObj.startTime)
$scope.selectedChannel = newObj
$scope.selectedChannelIndex = index
}
$scope.showChannelConfig = true
}
}

View File

@ -0,0 +1,5 @@
module.exports = function ($rootScope, $scope, pseudotv, plex, $location) {
$scope.selected = $location.hash()
if ($scope.selected === '')
$scope.selected = 'xmltv'
}

View File

@ -0,0 +1,111 @@
module.exports = function (plex, $timeout) {
return {
restrict: 'E',
templateUrl: 'templates/channel-config.html',
replace: true,
scope: {
channels: "=channels",
channel: "=channel",
onDone: "=onDone"
},
link: function (scope, element, attrs) {
if (typeof scope.channel === 'undefined' || scope.channel == null) {
scope.channel = {}
scope.channel.programs = []
scope.isNewChannel = true
scope.channel.icon = ""
scope.channel.startTime = new Date()
scope.channel.startTime.setMilliseconds(0)
scope.channel.startTime.setSeconds(0)
if (scope.channel.startTime.getMinutes() < 30)
scope.channel.startTime.setMinutes(0)
else
scope.channel.startTime.setMinutes(30)
if (scope.channels.length > 0) {
scope.channel.number = scope.channels[scope.channels.length - 1].number + 1
scope.channel.name = "Channel " + scope.channel.number
} else {
scope.channel.number = 1
scope.channel.name = "Channel 1"
}
} else {
scope.beforeEditChannelNumber = scope.channel.number
}
scope.finshedProgramEdit = (program) => {
scope.channel.programs[scope.selectedProgram] = program
scope._selectedProgram = null
updateChannelDuration()
}
scope.$watch('channel.startTime', () => {
updateChannelDuration()
})
function updateChannelDuration() {
scope.channel.duration = 0
for (let i = 0, l = scope.channel.programs.length; i < l; i++) {
scope.channel.programs[i].start = new Date(scope.channel.startTime.valueOf() + scope.channel.duration)
scope.channel.duration += scope.channel.programs[i].duration
scope.channel.programs[i].stop = new Date(scope.channel.startTime.valueOf() + scope.channel.duration)
}
}
scope.error = {}
scope._onDone = (channel) => {
if (typeof channel === 'undefined')
scope.onDone()
else {
channelNumbers = []
for (let i = 0, l = scope.channels.length; i < l; i++) {
channelNumbers.push(scope.channels[i].number)
}
// validate
var now = new Date()
console.log(channel.startTime.toLocaleString())
if (typeof channel.number === "undefined" || channel.number === null || channel.number === "") {
scope.error.number = "Select a channel number"
} else if (channelNumbers.indexOf(parseInt(channel.number, 10)) !== -1 && scope.isNewChannel) { // we need the parseInt for indexOf to work properly
scope.error.number = "Channel number already in use."
} else if (!scope.isNewChannel && channel.number !== scope.beforeEditChannelNumber && channelNumbers.indexOf(parseInt(channel.number, 10)) !== -1) {
scope.error.number = "Channel number already in use."
} else if (channel.number <= 0 || channel.number >= 2000) {
scope.error.name = "Enter a valid number (1-2000)"
} else if (typeof channel.name === "undefined" || channel.name === null || channel.name === "") {
scope.error.name = "Enter a channel name."
} else if (channel.icon !== "" && !validURL(channel.icon)) {
scope.error.icon = "Please enter a valid image URL. Or leave blank."
} else if (now < channel.startTime) {
scope.error.startTime = "Start time must not be set in the future."
} else if (channel.programs.length === 0) {
scope.error.programs = "No programs have been selected. Select at least one program."
} else {
// DONE.
scope.onDone(JSON.parse(angular.toJson(channel)))
}
$timeout(() => { scope.error = {} }, 3500)
}
}
scope.importPrograms = (selectedPrograms) => {
for (let i = 0, l = selectedPrograms.length; i < l; i++)
selectedPrograms[i].commercials = []
scope.channel.programs = scope.channel.programs.concat(selectedPrograms)
updateChannelDuration()
}
scope.selectProgram = (index) => {
scope.selectedProgram = index
scope._selectedProgram = JSON.parse(angular.toJson(scope.channel.programs[index]))
}
scope.removeItem = (x) => {
scope.channel.programs.splice(x, 1)
updateChannelDuration()
}
}
}
}
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);
}

View File

@ -0,0 +1,24 @@
module.exports = function (pseudotv, $interval) {
return {
restrict: 'E',
templateUrl: 'templates/ffmpeg-settings.html',
replace: true,
scope: {
},
link: function (scope, element, attrs) {
pseudotv.getFfmpegSettings().then((settings) => {
scope.settings = settings
})
scope.updateSettings = (settings) => {
pseudotv.updateFfmpegSettings(settings).then((_settings) => {
scope.settings = _settings
})
}
scope.resetSettings = (settings) => {
pseudotv.resetFfmpegSettings(settings).then((_settings) => {
scope.settings = _settings
})
}
}
}
}

View File

@ -0,0 +1,33 @@
module.exports = function (pseudotv, $timeout) {
return {
restrict: 'E',
templateUrl: 'templates/hdhr-settings.html',
replace: true,
scope: {
},
link: function (scope, element, attrs) {
pseudotv.getHdhrSettings().then((settings) => {
scope.settings = settings
})
scope.updateSettings = (settings) => {
if (settings.tunerCount == null) {
scope.error = { tunerCount: "Please enter a valid number of tuners." }
} else if (settings.tunerCount <= 0) {
scope.error = { tunerCount: "Tuner count must be greater than 0." }
}
if (scope.error != null)
$timeout(() => {
scope.error = null
}, 3500)
pseudotv.updateHdhrSettings(settings).then((_settings) => {
scope.settings = _settings
})
}
scope.resetSettings = (settings) => {
pseudotv.resetHdhrSettings(settings).then((_settings) => {
scope.settings = _settings
})
}
}
}
}

View File

@ -0,0 +1,83 @@
module.exports = function (plex, pseudotv) {
return {
restrict: 'E',
templateUrl: 'templates/plex-library.html',
replace: true,
scope: {
onFinish: "=onFinish",
height: "=height",
visible: "=visible"
},
link: function (scope, element, attrs) {
scope.selection = []
scope.selectServer = function (server) {
scope.plexServer = server
updateLibrary(server)
}
scope._onFinish = (s) => {
scope.onFinish(JSON.parse(angular.toJson(s)))
scope.selection = []
scope.visible = false
}
scope.selectItem = (item) => {
scope.selection.push(JSON.parse(angular.toJson(item)))
}
pseudotv.getPlexServers().then((servers) => {
if (servers.length === 0) {
scope.noServers = true
return
}
scope.plexServers = servers
scope.plexServer = servers[0]
updateLibrary(scope.plexServer)
})
function updateLibrary(server) {
plex.getLibrary(server).then((lib) => {
plex.getPlaylists(server).then((play) => {
for (let i = 0, l = play.length; i < l; i++)
play[i].type = 'playlist'
scope.$apply(() => {
scope.libraries = lib
if (play.length > 0)
scope.libraries.push({ title: "Playlists", key: "", icon: "", nested: play, collapse: false })
})
})
}, (err) => {
console.log(err)
})
}
scope.getNested = function (list) {
if (typeof list.collapse == 'undefined') {
plex.getNested(scope.plexServer, list.key).then((res) => {
list.nested = res
list.collapse = true
scope.$apply()
}, (err) => {
console.log(err)
})
} else {
list.collapse = !list.collapse
}
}
scope.selectPlaylist = (playlist) => {
if (typeof playlist.collapse == 'undefined') {
plex.getNested(scope.plexServer, playlist.key).then((res) => {
playlist.nested = res
for (let i = 0, l = playlist.nested.length; i < l; i++)
scope.selectItem(playlist.nested[i])
scope.$apply()
}, (err) => {
console.log(err)
})
} else {
for (let i = 0, l = playlist.nested.length; i < l; i++)
scope.selectItem(playlist.nested[i])
}
}
scope.createShowIdentifier = (season, ep) => {
return 'S' + (season.toString().padStart(2, '0')) + 'E' + (ep.toString().padStart(2, '0'))
}
}
};
}

View File

@ -0,0 +1,48 @@
module.exports = function (plex, pseudotv, $timeout) {
return {
restrict: 'E',
templateUrl: 'templates/plex-settings.html',
replace: true,
scope: {},
link: function (scope, element, attrs) {
pseudotv.getPlexServers().then((servers) => {
scope.servers = servers
})
scope.plex = { protocol: 'http', host: '127.0.0.1', port: '32400', username: '', password: '', arGuide: false, arChannels: false }
scope.addPlexServer = function (p) {
scope.isProcessing = true
plex.login(p)
.then((result) => {
delete p['username']
delete p['password']
p.token = result.token
p.name = result.name
return pseudotv.addPlexServer(p)
}).then((servers) => {
scope.$apply(() => {
scope.servers = servers
scope.isProcessing = false
scope.visible = false
})
}, (err) => {
scope.$apply(() => {
scope.isProcessing = false
scope.error = err
$timeout(() => {
scope.error = null
}, 3500)
})
})
}
scope.deletePlexServer = (x) => {
pseudotv.removePlexServer(x)
.then((servers) => {
scope.servers = servers
})
}
scope.toggleVisiblity = function () {
scope.visible = !scope.visible
}
}
};
}

View File

@ -0,0 +1,49 @@
module.exports = function ($timeout) {
return {
restrict: 'E',
templateUrl: 'templates/program-config.html',
replace: true,
scope: {
program: "=program",
visible: "=visible",
onDone: "=onDone"
},
link: function (scope, element, attrs) {
scope.selectedCommercials = (items) => {
scope.program.commercials = scope.program.commercials.concat(items)
for (let i = 0, l = scope.program.commercials.length; i < l; i++) {
if (typeof scope.program.commercials[i].commercialPosition === 'undefined')
scope.program.commercials[i].commercialPosition = 0
}
}
scope.finished = (prog) => {
if (prog.title === "") {
scope.error = { title: 'You must set a program title.' }
} else if (prog.type === "episode" && prog.showTitle == "") {
scope.error = { showTitle: 'You must set a show title when the program type is an episode.' }
} else if (prog.type === "episode" && (prog.season == null)) {
scope.error = { season: 'You must set a season number when the program type is an episode.' }
} else if (prog.type === "episode" && prog.season <= 0) {
scope.error = { season: 'Season number musat be greater than 0' }
} else if (prog.type === "episode" && (prog.episode == null)) {
scope.error = { episode: 'You must set a episode number when the program type is an episode.' }
} else if (prog.type === "episode" && prog.episode <= 0) {
scope.error = { episode: 'Episode number musat be greater than 0' }
}
if (scope.error != null) {
$timeout(() => {
scope.error = null
}, 3500)
return
}
prog.duration = prog.actualDuration
for (let i = 0, l = prog.commercials.length; i < l; i++)
prog.duration += prog.commercials[i].duration
scope.onDone(JSON.parse(angular.toJson(prog)))
scope.program = null
}
}
};
}

View File

@ -0,0 +1,24 @@
module.exports = function (pseudotv, $interval) {
return {
restrict: 'E',
templateUrl: 'templates/xmltv-settings.html',
replace: true,
scope: {
},
link: function (scope, element, attrs) {
pseudotv.getXmltvSettings().then((settings) => {
scope.settings = settings
})
scope.updateSettings = (settings) => {
pseudotv.updateXmltvSettings(settings).then((_settings) => {
scope.settings = _settings
})
}
scope.resetSettings = (settings) => {
pseudotv.resetXmltvSettings(settings).then((_settings) => {
scope.settings = _settings
})
}
}
}
}

650
web/ext/dragdrop.js vendored Normal file
View File

@ -0,0 +1,650 @@
/**
* angular-drag-and-drop-lists v2.1.0
*
* Copyright (c) 2014 Marcel Juenemann marcel@juenemann.cc
* Copyright (c) 2014-2017 Google Inc.
* https://github.com/marceljuenemann/angular-drag-and-drop-lists
*
* License: MIT
*/
(function(dndLists) {
// In standard-compliant browsers we use a custom mime type and also encode the dnd-type in it.
// However, IE and Edge only support a limited number of mime types. The workarounds are described
// in https://github.com/marceljuenemann/angular-drag-and-drop-lists/wiki/Data-Transfer-Design
var MIME_TYPE = 'application/x-dnd';
var EDGE_MIME_TYPE = 'application/json';
var MSIE_MIME_TYPE = 'Text';
// All valid HTML5 drop effects, in the order in which we prefer to use them.
var ALL_EFFECTS = ['move', 'copy', 'link'];
/**
* Use the dnd-draggable attribute to make your element draggable
*
* Attributes:
* - dnd-draggable Required attribute. The value has to be an object that represents the data
* of the element. In case of a drag and drop operation the object will be
* serialized and unserialized on the receiving end.
* - dnd-effect-allowed Use this attribute to limit the operations that can be performed. Valid
* options are "move", "copy" and "link", as well as "all", "copyMove",
* "copyLink" and "linkMove". The semantics of these operations are up to you
* and have to be implemented using the callbacks described below. If you
* allow multiple options, the user can choose between them by using the
* modifier keys (OS specific). The cursor will be changed accordingly,
* expect for IE and Edge, where this is not supported.
* - dnd-type Use this attribute if you have different kinds of items in your
* application and you want to limit which items can be dropped into which
* lists. Combine with dnd-allowed-types on the dnd-list(s). This attribute
* must be a lower case string. Upper case characters can be used, but will
* be converted to lower case automatically.
* - dnd-disable-if You can use this attribute to dynamically disable the draggability of the
* element. This is useful if you have certain list items that you don't want
* to be draggable, or if you want to disable drag & drop completely without
* having two different code branches (e.g. only allow for admins).
*
* Callbacks:
* - dnd-dragstart Callback that is invoked when the element was dragged. The original
* dragstart event will be provided in the local event variable.
* - dnd-moved Callback that is invoked when the element was moved. Usually you will
* remove your element from the original list in this callback, since the
* directive is not doing that for you automatically. The original dragend
* event will be provided in the local event variable.
* - dnd-copied Same as dnd-moved, just that it is called when the element was copied
* instead of moved, so you probably want to implement a different logic.
* - dnd-linked Same as dnd-moved, just that it is called when the element was linked
* instead of moved, so you probably want to implement a different logic.
* - dnd-canceled Callback that is invoked if the element was dragged, but the operation was
* canceled and the element was not dropped. The original dragend event will
* be provided in the local event variable.
* - dnd-dragend Callback that is invoked when the drag operation ended. Available local
* variables are event and dropEffect.
* - dnd-selected Callback that is invoked when the element was clicked but not dragged.
* The original click event will be provided in the local event variable.
* - dnd-callback Custom callback that is passed to dropzone callbacks and can be used to
* communicate between source and target scopes. The dropzone can pass user
* defined variables to this callback.
*
* CSS classes:
* - dndDragging This class will be added to the element while the element is being
* dragged. It will affect both the element you see while dragging and the
* source element that stays at it's position. Do not try to hide the source
* element with this class, because that will abort the drag operation.
* - dndDraggingSource This class will be added to the element after the drag operation was
* started, meaning it only affects the original element that is still at
* it's source position, and not the "element" that the user is dragging with
* his mouse pointer.
*/
dndLists.directive('dndDraggable', ['$parse', '$timeout', function($parse, $timeout) {
return function(scope, element, attr) {
// Set the HTML5 draggable attribute on the element.
element.attr("draggable", "true");
// If the dnd-disable-if attribute is set, we have to watch that.
if (attr.dndDisableIf) {
scope.$watch(attr.dndDisableIf, function(disabled) {
element.attr("draggable", !disabled);
});
}
/**
* When the drag operation is started we have to prepare the dataTransfer object,
* which is the primary way we communicate with the target element
*/
element.on('dragstart', function(event) {
event = event.originalEvent || event;
// Check whether the element is draggable, since dragstart might be triggered on a child.
if (element.attr('draggable') == 'false') return true;
// Initialize global state.
dndState.isDragging = true;
dndState.itemType = attr.dndType && scope.$eval(attr.dndType).toLowerCase();
// Set the allowed drop effects. See below for special IE handling.
dndState.dropEffect = "none";
dndState.effectAllowed = attr.dndEffectAllowed || ALL_EFFECTS[0];
event.dataTransfer.effectAllowed = dndState.effectAllowed;
// Internet Explorer and Microsoft Edge don't support custom mime types, see design doc:
// https://github.com/marceljuenemann/angular-drag-and-drop-lists/wiki/Data-Transfer-Design
var item = scope.$eval(attr.dndDraggable);
var mimeType = MIME_TYPE + (dndState.itemType ? ('-' + dndState.itemType) : '');
try {
event.dataTransfer.setData(mimeType, angular.toJson(item));
} catch (e) {
// Setting a custom MIME type did not work, we are probably in IE or Edge.
var data = angular.toJson({item: item, type: dndState.itemType});
try {
event.dataTransfer.setData(EDGE_MIME_TYPE, data);
} catch (e) {
// We are in Internet Explorer and can only use the Text MIME type. Also note that IE
// does not allow changing the cursor in the dragover event, therefore we have to choose
// the one we want to display now by setting effectAllowed.
var effectsAllowed = filterEffects(ALL_EFFECTS, dndState.effectAllowed);
event.dataTransfer.effectAllowed = effectsAllowed[0];
event.dataTransfer.setData(MSIE_MIME_TYPE, data);
}
}
// Add CSS classes. See documentation above.
element.addClass("dndDragging");
$timeout(function() { element.addClass("dndDraggingSource"); }, 0);
// Try setting a proper drag image if triggered on a dnd-handle (won't work in IE).
if (event._dndHandle && event.dataTransfer.setDragImage) {
event.dataTransfer.setDragImage(element[0], 0, 0);
}
// Invoke dragstart callback and prepare extra callback for dropzone.
$parse(attr.dndDragstart)(scope, {event: event});
if (attr.dndCallback) {
var callback = $parse(attr.dndCallback);
dndState.callback = function(params) { return callback(scope, params || {}); };
}
event.stopPropagation();
});
/**
* The dragend event is triggered when the element was dropped or when the drag
* operation was aborted (e.g. hit escape button). Depending on the executed action
* we will invoke the callbacks specified with the dnd-moved or dnd-copied attribute.
*/
element.on('dragend', function(event) {
event = event.originalEvent || event;
// Invoke callbacks. Usually we would use event.dataTransfer.dropEffect to determine
// the used effect, but Chrome has not implemented that field correctly. On Windows
// it always sets it to 'none', while Chrome on Linux sometimes sets it to something
// else when it's supposed to send 'none' (drag operation aborted).
scope.$apply(function() {
var dropEffect = dndState.dropEffect;
var cb = {copy: 'dndCopied', link: 'dndLinked', move: 'dndMoved', none: 'dndCanceled'};
$parse(attr[cb[dropEffect]])(scope, {event: event});
$parse(attr.dndDragend)(scope, {event: event, dropEffect: dropEffect});
});
// Clean up
dndState.isDragging = false;
dndState.callback = undefined;
element.removeClass("dndDragging");
element.removeClass("dndDraggingSource");
event.stopPropagation();
// In IE9 it is possible that the timeout from dragstart triggers after the dragend handler.
$timeout(function() { element.removeClass("dndDraggingSource"); }, 0);
});
/**
* When the element is clicked we invoke the callback function
* specified with the dnd-selected attribute.
*/
element.on('click', function(event) {
if (!attr.dndSelected) return;
event = event.originalEvent || event;
scope.$apply(function() {
$parse(attr.dndSelected)(scope, {event: event});
});
// Prevent triggering dndSelected in parent elements.
event.stopPropagation();
});
/**
* Workaround to make element draggable in IE9
*/
element.on('selectstart', function() {
if (this.dragDrop) this.dragDrop();
});
};
}]);
/**
* Use the dnd-list attribute to make your list element a dropzone. Usually you will add a single
* li element as child with the ng-repeat directive. If you don't do that, we will not be able to
* position the dropped element correctly. If you want your list to be sortable, also add the
* dnd-draggable directive to your li element(s).
*
* Attributes:
* - dnd-list Required attribute. The value has to be the array in which the data of
* the dropped element should be inserted. The value can be blank if used
* with a custom dnd-drop handler that always returns true.
* - dnd-allowed-types Optional array of allowed item types. When used, only items that had a
* matching dnd-type attribute will be dropable. Upper case characters will
* automatically be converted to lower case.
* - dnd-effect-allowed Optional string expression that limits the drop effects that can be
* performed in the list. See dnd-effect-allowed on dnd-draggable for more
* details on allowed options. The default value is all.
* - dnd-disable-if Optional boolean expresssion. When it evaluates to true, no dropping
* into the list is possible. Note that this also disables rearranging
* items inside the list.
* - dnd-horizontal-list Optional boolean expresssion. When it evaluates to true, the positioning
* algorithm will use the left and right halfs of the list items instead of
* the upper and lower halfs.
* - dnd-external-sources Optional boolean expression. When it evaluates to true, the list accepts
* drops from sources outside of the current browser tab. This allows to
* drag and drop accross different browser tabs. The only major browser
* that does not support this is currently Microsoft Edge.
*
* Callbacks:
* - dnd-dragover Optional expression that is invoked when an element is dragged over the
* list. If the expression is set, but does not return true, the element is
* not allowed to be dropped. The following variables will be available:
* - event: The original dragover event sent by the browser.
* - index: The position in the list at which the element would be dropped.
* - type: The dnd-type set on the dnd-draggable, or undefined if non was
* set. Will be null for drops from external sources in IE and Edge,
* since we don't know the type in those cases.
* - dropEffect: One of move, copy or link, see dnd-effect-allowed.
* - external: Whether the element was dragged from an external source.
* - callback: If dnd-callback was set on the source element, this is a
* function reference to the callback. The callback can be invoked with
* custom variables like this: callback({var1: value1, var2: value2}).
* The callback will be executed on the scope of the source element. If
* dnd-external-sources was set and external is true, this callback will
* not be available.
* - dnd-drop Optional expression that is invoked when an element is dropped on the
* list. The same variables as for dnd-dragover will be available, with the
* exception that type is always known and therefore never null. There
* will also be an item variable, which is the transferred object. The
* return value determines the further handling of the drop:
* - falsy: The drop will be canceled and the element won't be inserted.
* - true: Signalises that the drop is allowed, but the dnd-drop
* callback already took care of inserting the element.
* - otherwise: All other return values will be treated as the object to
* insert into the array. In most cases you want to simply return the
* item parameter, but there are no restrictions on what you can return.
* - dnd-inserted Optional expression that is invoked after a drop if the element was
* actually inserted into the list. The same local variables as for
* dnd-drop will be available. Note that for reorderings inside the same
* list the old element will still be in the list due to the fact that
* dnd-moved was not called yet.
*
* CSS classes:
* - dndPlaceholder When an element is dragged over the list, a new placeholder child
* element will be added. This element is of type li and has the class
* dndPlaceholder set. Alternatively, you can define your own placeholder
* by creating a child element with dndPlaceholder class.
* - dndDragover Will be added to the list while an element is dragged over the list.
*/
dndLists.directive('dndList', ['$parse', function($parse) {
return function(scope, element, attr) {
// While an element is dragged over the list, this placeholder element is inserted
// at the location where the element would be inserted after dropping.
var placeholder = getPlaceholderElement();
placeholder.remove();
var placeholderNode = placeholder[0];
var listNode = element[0];
var listSettings = {};
/**
* The dragenter event is fired when a dragged element or text selection enters a valid drop
* target. According to the spec, we either need to have a dropzone attribute or listen on
* dragenter events and call preventDefault(). It should be noted though that no browser seems
* to enforce this behaviour.
*/
element.on('dragenter', function (event) {
event = event.originalEvent || event;
// Calculate list properties, so that we don't have to repeat this on every dragover event.
var types = attr.dndAllowedTypes && scope.$eval(attr.dndAllowedTypes);
listSettings = {
allowedTypes: angular.isArray(types) && types.join('|').toLowerCase().split('|'),
disabled: attr.dndDisableIf && scope.$eval(attr.dndDisableIf),
externalSources: attr.dndExternalSources && scope.$eval(attr.dndExternalSources),
horizontal: attr.dndHorizontalList && scope.$eval(attr.dndHorizontalList)
};
var mimeType = getMimeType(event.dataTransfer.types);
if (!mimeType || !isDropAllowed(getItemType(mimeType))) return true;
event.preventDefault();
});
/**
* The dragover event is triggered "every few hundred milliseconds" while an element
* is being dragged over our list, or over an child element.
*/
element.on('dragover', function(event) {
event = event.originalEvent || event;
// Check whether the drop is allowed and determine mime type.
var mimeType = getMimeType(event.dataTransfer.types);
var itemType = getItemType(mimeType);
if (!mimeType || !isDropAllowed(itemType)) return true;
// Make sure the placeholder is shown, which is especially important if the list is empty.
if (placeholderNode.parentNode != listNode) {
element.append(placeholder);
}
if (event.target != listNode) {
// Try to find the node direct directly below the list node.
var listItemNode = event.target;
while (listItemNode.parentNode != listNode && listItemNode.parentNode) {
listItemNode = listItemNode.parentNode;
}
if (listItemNode.parentNode == listNode && listItemNode != placeholderNode) {
// If the mouse pointer is in the upper half of the list item element,
// we position the placeholder before the list item, otherwise after it.
var rect = listItemNode.getBoundingClientRect();
if (listSettings.horizontal) {
var isFirstHalf = event.clientX < rect.left + rect.width / 2;
} else {
var isFirstHalf = event.clientY < rect.top + rect.height / 2;
}
listNode.insertBefore(placeholderNode,
isFirstHalf ? listItemNode : listItemNode.nextSibling);
}
}
// In IE we set a fake effectAllowed in dragstart to get the correct cursor, we therefore
// ignore the effectAllowed passed in dataTransfer. We must also not access dataTransfer for
// drops from external sources, as that throws an exception.
var ignoreDataTransfer = mimeType == MSIE_MIME_TYPE;
var dropEffect = getDropEffect(event, ignoreDataTransfer);
if (dropEffect == 'none') return stopDragover();
// At this point we invoke the callback, which still can disallow the drop.
// We can't do this earlier because we want to pass the index of the placeholder.
if (attr.dndDragover && !invokeCallback(attr.dndDragover, event, dropEffect, itemType)) {
return stopDragover();
}
// Set dropEffect to modify the cursor shown by the browser, unless we're in IE, where this
// is not supported. This must be done after preventDefault in Firefox.
event.preventDefault();
if (!ignoreDataTransfer) {
event.dataTransfer.dropEffect = dropEffect;
}
element.addClass("dndDragover");
event.stopPropagation();
return false;
});
/**
* When the element is dropped, we use the position of the placeholder element as the
* position where we insert the transferred data. This assumes that the list has exactly
* one child element per array element.
*/
element.on('drop', function(event) {
event = event.originalEvent || event;
// Check whether the drop is allowed and determine mime type.
var mimeType = getMimeType(event.dataTransfer.types);
var itemType = getItemType(mimeType);
if (!mimeType || !isDropAllowed(itemType)) return true;
// The default behavior in Firefox is to interpret the dropped element as URL and
// forward to it. We want to prevent that even if our drop is aborted.
event.preventDefault();
// Unserialize the data that was serialized in dragstart.
try {
var data = JSON.parse(event.dataTransfer.getData(mimeType));
} catch(e) {
return stopDragover();
}
// Drops with invalid types from external sources might not have been filtered out yet.
if (mimeType == MSIE_MIME_TYPE || mimeType == EDGE_MIME_TYPE) {
itemType = data.type || undefined;
data = data.item;
if (!isDropAllowed(itemType)) return stopDragover();
}
// Special handling for internal IE drops, see dragover handler.
var ignoreDataTransfer = mimeType == MSIE_MIME_TYPE;
var dropEffect = getDropEffect(event, ignoreDataTransfer);
if (dropEffect == 'none') return stopDragover();
// Invoke the callback, which can transform the transferredObject and even abort the drop.
var index = getPlaceholderIndex();
if (attr.dndDrop) {
data = invokeCallback(attr.dndDrop, event, dropEffect, itemType, index, data);
if (!data) return stopDragover();
}
// The drop is definitely going to happen now, store the dropEffect.
dndState.dropEffect = dropEffect;
if (!ignoreDataTransfer) {
event.dataTransfer.dropEffect = dropEffect;
}
// Insert the object into the array, unless dnd-drop took care of that (returned true).
if (data !== true) {
scope.$apply(function() {
scope.$eval(attr.dndList).splice(index, 0, data);
});
}
invokeCallback(attr.dndInserted, event, dropEffect, itemType, index, data);
// Clean up
stopDragover();
event.stopPropagation();
return false;
});
/**
* We have to remove the placeholder when the element is no longer dragged over our list. The
* problem is that the dragleave event is not only fired when the element leaves our list,
* but also when it leaves a child element. Therefore, we determine whether the mouse cursor
* is still pointing to an element inside the list or not.
*/
element.on('dragleave', function(event) {
event = event.originalEvent || event;
var newTarget = document.elementFromPoint(event.clientX, event.clientY);
if (listNode.contains(newTarget) && !event._dndPhShown) {
// Signalize to potential parent lists that a placeholder is already shown.
event._dndPhShown = true;
} else {
stopDragover();
}
});
/**
* Given the types array from the DataTransfer object, returns the first valid mime type.
* A type is valid if it starts with MIME_TYPE, or it equals MSIE_MIME_TYPE or EDGE_MIME_TYPE.
*/
function getMimeType(types) {
if (!types) return MSIE_MIME_TYPE; // IE 9 workaround.
for (var i = 0; i < types.length; i++) {
if (types[i] == MSIE_MIME_TYPE || types[i] == EDGE_MIME_TYPE ||
types[i].substr(0, MIME_TYPE.length) == MIME_TYPE) {
return types[i];
}
}
return null;
}
/**
* Determines the type of the item from the dndState, or from the mime type for items from
* external sources. Returns undefined if no item type was set and null if the item type could
* not be determined.
*/
function getItemType(mimeType) {
if (dndState.isDragging) return dndState.itemType || undefined;
if (mimeType == MSIE_MIME_TYPE || mimeType == EDGE_MIME_TYPE) return null;
return (mimeType && mimeType.substr(MIME_TYPE.length + 1)) || undefined;
}
/**
* Checks various conditions that must be fulfilled for a drop to be allowed, including the
* dnd-allowed-types attribute. If the item Type is unknown (null), the drop will be allowed.
*/
function isDropAllowed(itemType) {
if (listSettings.disabled) return false;
if (!listSettings.externalSources && !dndState.isDragging) return false;
if (!listSettings.allowedTypes || itemType === null) return true;
return itemType && listSettings.allowedTypes.indexOf(itemType) != -1;
}
/**
* Determines which drop effect to use for the given event. In Internet Explorer we have to
* ignore the effectAllowed field on dataTransfer, since we set a fake value in dragstart.
* In those cases we rely on dndState to filter effects. Read the design doc for more details:
* https://github.com/marceljuenemann/angular-drag-and-drop-lists/wiki/Data-Transfer-Design
*/
function getDropEffect(event, ignoreDataTransfer) {
var effects = ALL_EFFECTS;
if (!ignoreDataTransfer) {
effects = filterEffects(effects, event.dataTransfer.effectAllowed);
}
if (dndState.isDragging) {
effects = filterEffects(effects, dndState.effectAllowed);
}
if (attr.dndEffectAllowed) {
effects = filterEffects(effects, attr.dndEffectAllowed);
}
// MacOS automatically filters dataTransfer.effectAllowed depending on the modifier keys,
// therefore the following modifier keys will only affect other operating systems.
if (!effects.length) {
return 'none';
} else if (event.ctrlKey && effects.indexOf('copy') != -1) {
return 'copy';
} else if (event.altKey && effects.indexOf('link') != -1) {
return 'link';
} else {
return effects[0];
}
}
/**
* Small helper function that cleans up if we aborted a drop.
*/
function stopDragover() {
placeholder.remove();
element.removeClass("dndDragover");
return true;
}
/**
* Invokes a callback with some interesting parameters and returns the callbacks return value.
*/
function invokeCallback(expression, event, dropEffect, itemType, index, item) {
return $parse(expression)(scope, {
callback: dndState.callback,
dropEffect: dropEffect,
event: event,
external: !dndState.isDragging,
index: index !== undefined ? index : getPlaceholderIndex(),
item: item || undefined,
type: itemType
});
}
/**
* We use the position of the placeholder node to determine at which position of the array the
* object needs to be inserted
*/
function getPlaceholderIndex() {
return Array.prototype.indexOf.call(listNode.children, placeholderNode);
}
/**
* Tries to find a child element that has the dndPlaceholder class set. If none was found, a
* new li element is created.
*/
function getPlaceholderElement() {
var placeholder;
angular.forEach(element.children(), function(childNode) {
var child = angular.element(childNode);
if (child.hasClass('dndPlaceholder')) {
placeholder = child;
}
});
return placeholder || angular.element("<li class='dndPlaceholder'></li>");
}
};
}]);
/**
* Use the dnd-nodrag attribute inside of dnd-draggable elements to prevent them from starting
* drag operations. This is especially useful if you want to use input elements inside of
* dnd-draggable elements or create specific handle elements. Note: This directive does not work
* in Internet Explorer 9.
*/
dndLists.directive('dndNodrag', function() {
return function(scope, element, attr) {
// Set as draggable so that we can cancel the events explicitly
element.attr("draggable", "true");
/**
* Since the element is draggable, the browser's default operation is to drag it on dragstart.
* We will prevent that and also stop the event from bubbling up.
*/
element.on('dragstart', function(event) {
event = event.originalEvent || event;
if (!event._dndHandle) {
// If a child element already reacted to dragstart and set a dataTransfer object, we will
// allow that. For example, this is the case for user selections inside of input elements.
if (!(event.dataTransfer.types && event.dataTransfer.types.length)) {
event.preventDefault();
}
event.stopPropagation();
}
});
/**
* Stop propagation of dragend events, otherwise dnd-moved might be triggered and the element
* would be removed.
*/
element.on('dragend', function(event) {
event = event.originalEvent || event;
if (!event._dndHandle) {
event.stopPropagation();
}
});
};
});
/**
* Use the dnd-handle directive within a dnd-nodrag element in order to allow dragging with that
* element after all. Therefore, by combining dnd-nodrag and dnd-handle you can allow
* dnd-draggable elements to only be dragged via specific "handle" elements. Note that Internet
* Explorer will show the handle element as drag image instead of the dnd-draggable element. You
* can work around this by styling the handle element differently when it is being dragged. Use
* the CSS selector .dndDragging:not(.dndDraggingSource) [dnd-handle] for that.
*/
dndLists.directive('dndHandle', function() {
return function(scope, element, attr) {
element.attr("draggable", "true");
element.on('dragstart dragend', function(event) {
event = event.originalEvent || event;
event._dndHandle = true;
});
};
});
/**
* Filters an array of drop effects using a HTML5 effectAllowed string.
*/
function filterEffects(effects, effectAllowed) {
if (effectAllowed == 'all') return effects;
return effects.filter(function(effect) {
return effectAllowed.toLowerCase().indexOf(effect) != -1;
});
}
/**
* For some features we need to maintain global state. This is done here, with these fields:
* - callback: A callback function set at dragstart that is passed to internal dropzone handlers.
* - dropEffect: Set in dragstart to "none" and to the actual value in the drop handler. We don't
* rely on the dropEffect passed by the browser, since there are various bugs in Chrome and
* Safari, and Internet Explorer defaults to copy if effectAllowed is copyMove.
* - effectAllowed: Set in dragstart based on dnd-effect-allowed. This is needed for IE because
* setting effectAllowed on dataTransfer might result in an undesired cursor.
* - isDragging: True between dragstart and dragend. Falsy for drops from external sources.
* - itemType: The item type of the dragged element set via dnd-type. This is needed because IE
* and Edge don't support custom mime types that we can use to transfer this information.
*/
var dndState = {};
})(angular.module('dndLists', []));

274
web/ext/lazyload.js Normal file
View File

@ -0,0 +1,274 @@
module.exports = function (angular) {
/*
* angular-lazy-load
*
* Copyright(c) 2014 Paweł Wszoła <wszola.p@gmail.com>
* MIT Licensed
*
*/
/**
* @author Paweł Wszoła (wszola.p@gmail.com)
*
*/
angular.module('angularLazyImg', []);
angular.module('angularLazyImg').factory('LazyImgMagic', [
'$window', '$rootScope', 'lazyImgConfig', 'lazyImgHelpers',
function($window, $rootScope, lazyImgConfig, lazyImgHelpers){
'use strict';
var winDimensions, $win, images, isListening, options;
var checkImagesT, saveWinOffsetT, containers;
images = [];
isListening = false;
options = lazyImgConfig.getOptions();
$win = angular.element($window);
winDimensions = lazyImgHelpers.getWinDimensions();
saveWinOffsetT = lazyImgHelpers.throttle(function(){
winDimensions = lazyImgHelpers.getWinDimensions();
}, 60);
options.container = options.containers || options.container;
containers = options.container ? [].concat(options.container) : [$win];
function checkImages(){
for(var i = images.length - 1; i >= 0; i--){
var image = images[i];
if(image && lazyImgHelpers.isElementInView(image.$elem[0], options.offset, winDimensions)){
loadImage(image);
images.splice(i, 1);
}
}
if(!images.length){ stopListening(); }
}
checkImagesT = lazyImgHelpers.throttle(checkImages, 30);
function listen(param){
containers.forEach(function (container) {
container[param]('scroll', checkImagesT);
container[param]('touchmove', checkImagesT);
});
$win[param]('resize', checkImagesT);
$win[param]('resize', saveWinOffsetT);
}
function startListening(){
isListening = true;
setTimeout(function(){
checkImages();
listen('on');
}, 1);
}
function stopListening(){
isListening = false;
listen('off');
}
function removeImage(image){
var index = images.indexOf(image);
if(index !== -1) {
images.splice(index, 1);
}
}
function loadImage(photo){
var img = new Image();
img.onerror = function(){
if(options.errorClass){
photo.$elem.addClass(options.errorClass);
}
if(photo.errorSrc){
setPhotoSrc(photo.$elem, photo.errorSrc);
}
$rootScope.$apply(function () {
$rootScope.$emit('lazyImg:error', photo);
options.onError(photo);
});
};
img.onload = function(){
setPhotoSrc(photo.$elem, photo.src);
if(options.successClass){
photo.$elem.addClass(options.successClass);
}
$rootScope.$apply(function () {
$rootScope.$emit('lazyImg:success', photo);
options.onSuccess(photo);
});
};
img.src = photo.src;
}
function setPhotoSrc($elem, src){
if ($elem[0].nodeName.toLowerCase() === 'img') {
$elem[0].src = src;
} else {
$elem.css('background-image', 'url("' + src + '")');
}
}
// PHOTO
function Photo($elem){
this.$elem = $elem;
}
Photo.prototype.setSource = function(source){
this.src = source;
images.unshift(this);
startListening();
};
Photo.prototype.setErrorSource = function(errorSource){
this.errorSrc = errorSource;
};
Photo.prototype.removeImage = function(){
removeImage(this);
if(!images.length){ stopListening(); }
};
Photo.prototype.checkImages = checkImages;
Photo.addContainer = function (container) {
stopListening();
containers.push(container);
startListening();
};
Photo.removeContainer = function (container) {
stopListening();
containers.splice(containers.indexOf(container), 1);
startListening();
};
return Photo;
}
]);
angular.module('angularLazyImg').provider('lazyImgConfig', function() {
'use strict';
this.options = {
offset : 100,
errorClass : null,
successClass : null,
onError : function(){},
onSuccess : function(){}
};
this.$get = function() {
var options = this.options;
return {
getOptions: function() {
return options;
}
};
};
this.setOptions = function(options) {
angular.extend(this.options, options);
};
});
angular.module('angularLazyImg').factory('lazyImgHelpers', [
'$window', function($window){
'use strict';
function getWinDimensions(){
return {
height: $window.innerHeight,
width: $window.innerWidth
};
}
function isElementInView(elem, offset, winDimensions) {
var rect = elem.getBoundingClientRect();
return (
// check if any part of element is in view extented by an offset
(rect.left <= winDimensions.width + offset) &&
(rect.right >= 0 - offset) &&
(rect.top <= winDimensions.height + offset) &&
(rect.bottom >= 0 - offset)
);
}
// http://remysharp.com/2010/07/21/throttling-function-calls/
function throttle(fn, threshhold, scope) {
var last, deferTimer;
return function () {
var context = scope || this;
var now = +new Date(),
args = arguments;
if (last && now < last + threshhold) {
clearTimeout(deferTimer);
deferTimer = setTimeout(function () {
last = now;
fn.apply(context, args);
}, threshhold);
} else {
last = now;
fn.apply(context, args);
}
};
}
return {
isElementInView: isElementInView,
getWinDimensions: getWinDimensions,
throttle: throttle
};
}
]);
angular.module('angularLazyImg')
.directive('lazyImg', [
'$rootScope', '$log', 'LazyImgMagic', function ($rootScope, $log, LazyImgMagic) {
'use strict';
function link(scope, element, attributes) {
scope.lazyImage = new LazyImgMagic(element);
scope.lazyImage.setErrorSource(attributes.lazyImgError);
var deregister = attributes.$observe('lazyImg', function (newSource) {
if (newSource) {
deregister();
scope.lazyImage.setSource(newSource);
}
});
var eventsDeregister = $rootScope.$on('lazyImg:refresh', function () {
scope.lazyImage.checkImages();
});
scope.$on('$destroy', function () {
scope.lazyImage.removeImage();
eventsDeregister();
});
}
return {
link: link,
restrict: 'A'
};
}
])
.directive('lazyImgContainer', [
'LazyImgMagic', function (LazyImgMagic) {
'use strict';
function link(scope, element) {
LazyImgMagic.addContainer(element);
scope.$on('$destroy', function () {
LazyImgMagic.removeContainer(element);
});
}
return {
link: link,
restrict: 'A'
};
}
]);
}

29
web/public/index.html Normal file
View File

@ -0,0 +1,29 @@
<html>
<head>
<title>PseudoTV</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css">
<link href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<link href="style.css" rel="stylesheet">
<script src="bundle.js"></script>
</head>
<body ng-app="myApp" style="min-width: 340px;">
<div class="container">
<h1>PseudoTV</h1>
<a href="#!/channels">Channels</a> - <a href="#!/settings">Settings</a>
<span class="pull-right">
<span style="margin-right: 15px;">
<a href="/api/xmltv.xml">XMLTV <span class="fa fa-file-code-o"></span></a>
</span>
<span>
<a href="/api/channels.m3u">M3U <span class="fa fa-file-movie-o"></span></a>
</span>
</span>
<hr/>
<div ng-view></div>
</div>
</body>
</html>

64
web/public/style.css Normal file
View File

@ -0,0 +1,64 @@
.commercials-panel {
background-color: rgb(70, 70, 70);
border-top: 1px solid #daa104;
border-left-color: #daa104;
border-right-color: #daa104;
color: white
}
.plex-panel {
margin: 0;
padding: 0;
overflow-y: scroll;
}
.flex-container {
display: flex;
align-items: center;
}
.flex-pull-right {
margin-left: auto;
padding-right: 20px
}
.list-group-item-video {
background-color: rgb(70, 70, 70);
border-top: 1px solid #daa104;
border-left-color: #daa104;
border-right-color: #daa104;
color: white
}
.list-group-item-video .fa-plus-circle {
color: #daa104;
}
.list-group-item-video:hover .fa-plus-circle {
color: #000;
}
.list-group-item-video:hover {
background-color: #daa104;
color: #000 !important;
}
.list-group.list-group-root .list-group-item {
border-radius: 0;
border-width: 1px 0 0 0;
padding: 0;
margin: 0;
cursor: pointer;
}
.list-group.list-group-root .list-group-item img {
height: 45px;
}
.list-group.list-group-root .list-group-item {
border-radius: 0;
border-width: 1px 0 0 0;
padding: 0;
margin: 0;
}
.list-group.list-group-root .list-group-item div .tab {
width: 25px;
display: inline-block;
text-align: center;
cursor: pointer;
}

View File

@ -0,0 +1,89 @@
<div>
<div class="modal" tabindex="-1" role="dialog" style="display: block; background-color: rgba(0, 0, 0, .5);">
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Channel Editor
</h5>
</div>
<div class="modal-body">
<div>
<div>
<span class="pull-right text-danger">{{error.number}}</span>
<label id="channelNumber" class="small">Ch. #</label>
<input for="channelNumber" class="form-control form-control-sm" type="number"
ng-model="channel.number"/>
</div>
<div>
<span class="pull-right text-danger">{{error.name}}</span>
<label id="channelName" class="small">Channel Name</label>
<input for="channelName" class="form-control form-control-sm" type="text"
ng-model="channel.name"/>
</div>
<div>
<span class="pull-right text-danger">{{error.icon}}</span>
<label id="channelIcon" class="small">Channel Icon</label>
<input for="channelIcon" class="form-control form-control-sm" type="url"
ng-model="channel.icon" />
</div>
<div>
<span class="pull-right text-danger">{{error.startTime}}</span>
<label id="channelStartTime" class="small">Channel Timeline Start {{maxDate}}</label>
<input for="channelStartTime" class="form-control form-control-sm" type="datetime-local" ng-model="channel.startTime"/>
</div>
</div>
<hr />
<div>
<h6>Programs
<span class="pull-right">
<span class="text-danger small">{{error.programs}}</span>
<button class="btn btn-sm btn-primary" ng-click="displayPlexLibrary = true">
<span class="fa fa-plus"></span>
</button>
</span>
</h6>
<div ng-if="channel.programs.length === 0">
<div class="small">Add programs to this channel by selecting media from your Plex library
</div>
<br />
<h5 class="text-center text-danger">No programs are currently scheduled</h5>
</div>
<div class="list-group list-group-root" dnd-list="channel.programs">
<li class="list-group-item flex-container" ng-repeat="x in channel.programs"
ng-click="selectProgram($index)" dnd-draggable="x" dnd-moved="channel.programs.splice($index, 1)"
dnd-effect-allowed="move">
<div class="small" style="width: 150px; margin-right: 5px;">
<div class="text-success">{{x.start.toLocaleString()}}</div>
<div class="text-danger">{{x.stop.toLocaleString()}}</div>
</div>
<div style="margin-right: 15px; text-align: center">
<span class="badge badge-dark">Commercials: {{x.commercials.length}}</span>
</div>
<div style="margin-right: 5px;">
{{ x.type === 'episode' ? x.showTitle + ' - S' + x.season.toString().padStart(2, '0') + 'E' + x.episode.toString().padStart(2, '0') : x.title}}
</div>
<span class="flex-pull-right btn fa fa-trash"
ng-click="removeItem($index); $event.stopPropagation()"></span>
</li>
</div>
</div>
</div>
<div class="modal-footer">
<div class="text-right">
<button class="btn btn-sm btn-link" ng-click="_onDone()">
Cancel
</button>
<button class="btn btn-sm btn-primary" ng-click="_onDone(channel)">
{{ isNewChannel ? 'Add Channel' : 'Update Channel' }}
</button>
</div>
</div>
</div>
</div>
</div>
<program-config program="_selectedProgram" on-done="finshedProgramEdit"></program-config>
<plex-library height="300" visible="displayPlexLibrary" on-finish="importPrograms"></plex-library>
</div>

View File

@ -0,0 +1,51 @@
<div>
<h5>FFMPEG Settings
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
Update
</button>
<button class="pull-right btn btn-sm btn-info" ng-click="resetSettings(settings)">
Reset Options
</button>
</h5>
<h6>FFMPEG Path</h6>
<input type="text" class="form-control form-control-sm" ng-model="settings.ffmpegPath"/>
<hr/>
<h6>Miscellaneous Options</h6>
<div class="row">
<div class="col-sm-6">
<label>Threads</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.threads"/>
</div>
<div class="col-sm-6">
<label>Buffer Size</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.bufSize"/>
</div>
</div>
<hr/>
<div class="row">
<div class="col-sm-6">
<h6>Video Options</h6>
<label>Video Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoEncoder"/>
<label>Video Bitrate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoBitrate"/>
<label>Video Resolution</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoResolution"/>
<label>Video Framerate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.videoFrameRate"/>
</div>
<div class="col-sm-6">
<h6>Audio Options</h6>
<label>Audio Encoder</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.audioEncoder"/>
<label>Audio Channels</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.audioChannels"/>
<label>Audio Bitrate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.audioBitrate"/>
<label>Audio Rate</label>
<input type="text" class="form-control form-control-sm" ng-model="settings.audioRate"/>
</div>
</div>
</div>

View File

@ -0,0 +1,29 @@
<div>
<h5>HDHR Settings
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
Update
</button>
<button class="pull-right btn btn-sm btn-info" ng-click="resetSettings(settings)">
Reset Options
</button>
</h5>
<br/>
<div class="row">
<div class="col-sm-6">
<h6>Auto-Discovery</h6>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="ad" ng-model="settings.autoDiscovery">
<label class="form-check-label" for="ad">Enable SSDP server</label>
</div>
<p class="text-center text-info">* Restart required</p>
</div>
<div class="col-sm-6">
<h6>Tuner Count
<span class="pull-right text-danger">{{error.tunerCount}}</span>
</h6>
<input type="number" class="form-control form-control-sm" ng-model="settings.tunerCount"/>
</div>
</div>
</div>

View File

@ -0,0 +1,117 @@
<div ng-show="visible">
<div class="modal" tabindex="-1" role="dialog" style="display: block; background-color: rgba(0, 0, 0, .5);">
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content" ng-if="noServers">
<div class="modal-header">
<h5 class="modal-title">Plex Library</h5>
</div>
<div class="model-body">
<br/>
<br/>
<br/>
<br/>
<p class="text-center">Configure your Plex Server(s) in <a href="/#!/settings#plex">Settings</a></p>
<br/>
<br/>
<br/>
<br/>
<br/>
</div>
</div>
<div class="modal-content" ng-if="!noServers">
<div class="modal-header">
<h5 class="modal-title">Plex Library</h5>
<span class="pull-right">
<label class="small" for="displayImages">Thumbnails</label>&nbsp;
<input id="displayImages" type="checkbox" ng-model="displayImages" />&nbsp;
</span>
</div>
<div class="modal-body">
<select class="form-control form-control-sm" ng-model="plexServer"
ng-options="x.name for x in plexServers" ng-change="selectServer(plexServer)"></select>
<hr />
<ul class="list-group list-group-root plex-panel" ng-init="setHeight = {'height': height + 'px'}" ng-style="setHeight" lazy-img-container>
<li class="list-group-item" ng-repeat="a in libraries">
<div class="{{ displayImages ? 'w_images' : 'wo_images' }}" ng-click="getNested(a);">
<span class="fa {{ a.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
<img ng-if="displayImages" lazy-img="{{a.icon}}" />
<span>{{a.title}}</span><!-- Library -->
</div>
<ul ng-if="a.collapse" class="list-group">
<li class="list-group-item {{ b.type !== 'movie' ? 'list-group-item-secondary' : 'list-group-item-video' }}"
ng-repeat="b in a.nested">
<div class="flex-container"
ng-click="b.type !== 'movie' ? getNested(b) : selectItem(b)">
<span ng-if="b.type === 'movie'" class="fa fa-plus-circle tab"></span>
<span ng-if="b.type !== 'movie'" class="tab"></span>
<span ng-if="b.type !== 'movie'" class="fa {{ b.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
<img ng-if="displayImages" lazy-img="{{ b.type === 'episode' ? b.episodeIcon : b.icon }}" />
{{b.title}}
<span ng-if="b.type === 'movie'" class="flex-pull-right">
{{b.durationStr}}
</span>
<span ng-if="b.type === 'playlist'" class="flex-pull-right" ng-click="selectPlaylist(b); $event.stopPropagation()">
<span class="fa fa-plus btn"></span>
</span>
</div>
<ul ng-if="b.collapse" class="list-group">
<li ng-repeat="c in b.nested"
class="list-group-item {{ c.type !== 'movie' && c.type !== 'episode' ? 'list-group-item-dark' : 'list-group-item-video' }}">
<div class="flex-container"
ng-click="c.type !== 'movie' && c.type !== 'episode' ? getNested(c) : selectItem(c)">
<span ng-if="c.type === 'movie' || c.type === 'episode'"
class="fa fa-plus-circle tab"></span>
<span ng-if="c.type !== 'movie' && c.type !== 'episode'"
class="tab"></span>
<span ng-if="c.type !== 'movie' && c.type !== 'episode'"
class="tab"></span>
<span ng-if="c.type !== 'movie' && c.type !== 'episode'"
class="fa {{ c.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
<img ng-if="displayImages" lazy-img="{{c.type === 'episode' ? c.episodeIcon : c.icon }}" />
{{ c.type === 'episode' ? c.showTitle + ' - S' + c.season.toString().padStart(2,'0') + 'E' + c.episode.toString().padStart(2,'0') + ' - ' : '' }}
{{c.title}}
<span ng-if="c.type === 'movie' || c.type === 'episode'"
class="flex-pull-right">
{{c.durationStr}}
</span>
</div>
<ul ng-if="c.collapse" class="list-group">
<li class="list-group-item list-group-item-video"
ng-repeat="d in c.nested">
<div class="flex-container" ng-click="selectItem(d)">
<span class="fa fa-plus-circle tab"></span>
<img ng-if="displayImages" lazy-img="{{d.episodeIcon}}" />
E{{ d.episode.toString().padStart(2,'0')}} - {{d.title}}
<span class="flex-pull-right">{{d.durationStr}}</span>
<!-- Episode -->
</div>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<hr/>
<h6>Selected Items</h6>
<ul class="list-group list-group-root" style="height: 180px; overflow-y: scroll" dnd-list="selection">
<div ng-if="selection.length === 0">Select media items from your plex library above.</div>
<li class="list-group-item" ng-repeat="x in selection" style="cursor:default;" dnd-draggable="x" dnd-moved="selection.splice($index, 1)" dnd-effect-allowed="move">
{{ (x.type !== 'episode') ? x.title : (x.showTitle + ' - S' + x.season.toString().padStart(2,'0') + 'E' + x.episode.toString().padStart(2,'0'))}}
<span class="pull-right">
<span class="btn fa fa-trash" ng-click="selection.splice($index,1)"></span>
</span>
</li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-link" ng-click="_onFinish([])">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="_onFinish(selection);">Done</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,87 @@
<div>
<h5>Plex Settings</h5>
<h6 ng-init="visible = false">Plex Servers
<button ng-hide="visible" class="pull-right btn btn-sm btn-primary" ng-click="toggleVisiblity()">
<span class="fa fa-plus"></span>
</button>
</h6>
<div ng-if="visible">
<form>
<h6>Add a Plex Server
<span class="pull-right text-danger">{{error}}</span>
</h6>
<div class="form-row">
<div class="form-group col-sm-2">
<select class="form-control form-control-sm" ng-model="plex.protocol">
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
<div class="form-group col-sm-5">
<input class="form-control form-control-sm" type="text" ng-model="plex.host" ng-disabled="isProcessing" placeholder="Plex host"/>
</div>
<div class="form-group col-sm-5">
<input class="form-control form-control-sm" type="text" ng-model="plex.port" ng-disabled="isProcessing" placeholder="Plex port"/>
</div>
</div>
<div class="form-row">
<div class="form-group col-sm-6">
<input class="form-control form-control-sm" type="text" ng-model="plex.username" ng-disabled="isProcessing" placeholder="Plex admin username"/>
</div>
<div class="form-group col-sm-6">
<input class="form-control form-control-sm" type="password" ng-model="plex.password" ng-disabled="isProcessing" placeholder="Plex admin password"/>
</div>
</div>
<div class="form-row">
<div class="form-group col-sm-4">
<div class="form-control form-control-sm">
<input id="arGuide" type="checkbox" ng-model="plex.arGuide" ng-disabled="isProcessing">
<label for="arGuide">Auto Refresh Guide</label>
</div>
</div>
<div class="form-group col-sm-4">
<div class="form-control form-control-sm">
<input id="arChannels" type="checkbox" ng-model="plex.arChannels" ng-disabled="isProcessing">
<label for="arChannels">Auto Map Channels</label>
</div>
</div>
<div class="form-group col-sm-4">
<span class="pull-right">
<button class="btn btn-sm btn-link" type="button" ng-click="toggleVisiblity()" ng-disabled="isProcessing">Cancel</button>
<input class="btn btn-sm btn-success" type="submit" ng-click="addPlexServer(plex)" ng-disabled="isProcessing" value="Add Server"/>
</span>
</div>
</div>
</form>
<p class="text-danger text-center">
Use your Plex servers network address (192.168.*.*) as your HOST. Avoid using loopbacks (127.0.0.1, localhost).<br/>
<b>WARNING - Do not check "Auto Map Channels" unless the PseudoTV tuner is the ONLY tuner added to your Plex Server.</b>
</p>
</div>
<table class="table">
<tr>
<th>Name</th>
<th>Address</th>
<th>Refresh Guide</th>
<th>Refresh Channels</th>
<th></th>
</tr>
<tr ng-if="servers.length === 0">
<td colspan="5">
<p class="text-center text-danger">Add a Plex Server</p>
</td>
</tr>
<tr ng-repeat="x in servers">
<td>{{ x.name }}</td>
<td>{{ x.protocol }}://{{ x.host }}:{{ x.port }}</td>
<td>{{ x.arGuide }}</td>
<td>{{ x.arChannels }}</td>
<td>
<button class="btn btn-sm btn-danger" ng-click="deletePlexServer(x._id)">
<span class="fa fa-minus"></span>
</button>
</td>
</tr>
</table>
</div>

View File

@ -0,0 +1,111 @@
<div ng-show="program">
<div class="modal" tabindex="-1" role="dialog" style="display: block; background-color: rgba(0, 0, 0, .5);">
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content">
<div>
<div class="modal-header">
<h5 class="modal-title">Program Config</h5>
</div>
</div>
<div class="modal-body container">
<select ng-model="program.type" class="pull-right">
<option>movie</option>
<option>episode</option>
</select>
<div ng-if="program.type === 'movie'">
<label>Movie Title
<span class="text-danger pull-right">{{error.title}}</span>
</label>
<input class="form-control form-control-sm" type="text" ng-model="program.title"/>
<label>Subtitle</label>
<input class="form-control form-control-sm" type="text" ng-model="program.subtitle"/>
<label>Summary</label>
<textarea class="form-control form-control-sm" ng-model="program.summary"></textarea>
<label>Rating</label>
<input class="form-control form-control-sm" type="text" ng-model="program.rating"/>
<label>Icon</label>
<input class="form-control form-control-sm" type="text" ng-model="program.icon"/>
<b>Icon Preview</b>
<div class="text-center">
<img class="img" ng-src="{{program.icon}}" style="max-width: 200px;"/>
</div>
</div>
<div ng-if="program.type === 'episode'">
<label>Show Title
<span class="text-danger pull-right">{{error.showTitle}}</span>
</label>
<input class="form-control form-control-sm" type="text" ng-model="program.showTitle"/>
<label>Episode Title
<span class="text-danger pull-right">{{error.title}}</span>
</label>
<input class="form-control form-control-sm" type="text" ng-model="program.title"/>
<label>Season
<span class="text-danger pull-right">{{error.season}}</span>
</label>
<input class="form-control form-control-sm" type="number" ng-model="program.season"/>
<label>Episode
<span class="text-danger pull-right">{{error.episode}}</span>
</label>
<input class="form-control form-control-sm" type="number" ng-model="program.episode"/>
<label>Summary</label>
<textarea class="form-control form-control-sm" ng-model="program.summary"></textarea>
<label>Rating</label>
<input class="form-control form-control-sm" type="text" ng-model="program.rating"/>
<label>Icon</label>
<input class="form-control form-control-sm" type="text" ng-model="program.icon"/>
<div class="row">
<div class="col-sm-6">
<b>Icon Preview</b>
<div class="text-center">
<img class="img" ng-src="{{program.icon}}" style="max-width: 200px;"/>
</div>
</div>
<div class="col-sm-6 row" ng-if="program.showIcon">
<div class="col-sm-6 text-center">
<label>Show</label>
<img class="img" ng-src="{{program.showIcon}}" style="max-width: 75px; cursor: pointer;" ng-click="program.icon = program.showIcon"/>
</div>
<div class="col-sm-6 text-center">
<label>Season</label>
<img class="img" ng-src="{{program.seasonIcon}}" style="max-width: 75px; cursor: pointer;" ng-click="program.icon = program.seasonIcon"/>
</div>
<div class="col-sm-12 text-center">
<label>Episode</label>
<img class="img" ng-src="{{program.episodeIcon}}" style="max-width: 150px; cursor: pointer;" ng-click="program.icon = program.episodeIcon"/>
</div>
</div>
</div>
</div>
<div>
<h6 style="margin-top: 10px;">Commercials
<button class="btn btn-sm btn-primary pull-right" ng-click="showPlexLibrary = true">
<span class="fa fa-plus"></span>
</button>
</h6>
<div class="list-group list-group-root" dnd-list="program.commercials">
<div class="list-group-item flex-container" style="cursor: default;" ng-repeat="x in program.commercials" dnd-draggable="x" dnd-moved="program.commercials.splice($index, 1)" dnd-effect-allowed="move">
{{x.title}}
<div class="flex-pull-right">
<span class="small" style="display: inline-block;">
<b>Position</b><br/>
{{x.commercialPosition===0?'START':x.commercialPosition=== 1?'1/4':x.commercialPosition===2?'1/2':x.commercialPosition===3?'3/4':'END'}}
</span>
<span style="padding-top: 10px; display: inline-block;">
<input type="range" min="0" max="4" ng-model="x.commercialPosition"/>
</span>
<span class="btn fa fa-trash" ng-click="program.commercials.splice($index,1)"></span>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-link" ng-click="program = null">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" ng-click="finished(program);">Done</button>
</div>
</div>
</div>
</div>
<plex-library height="300" visible="showPlexLibrary" on-finish="selectedCommercials"></plex-library>
</div>

View File

@ -0,0 +1,25 @@
<div>
<h5>XMLTV Settings
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
Update
</button>
<button class="pull-right btn btn-sm btn-info" ng-click="resetSettings(settings)">
Reset Options
</button>
</h5>
<h6>Output Path</h6>
<input type="text" class="form-control form-control-sm" ng-model="settings.file"/>
<br/>
<div class="row">
<div class="col-sm-6">
<label>EPG Cache (hours)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.cache"/>
</div>
<div class="col-sm-6">
<label>Refresh Timer (hours)</label>
<input type="number" class="form-control form-control-sm" ng-model="settings.refresh"/>
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
<div>
<channel-config ng-if="showChannelConfig" channel="selectedChannel" channels="channels" on-done="onChannelConfigDone"></channel-config>
<h5>
Channels
<button class="pull-right btn btn-sm btn-primary" ng-click="selectChannel(-1)">
<span class="fa fa-plus"></span>
</button>
</h5>
<table class="table">
<tr>
<th>Number</th>
<th>Name</th>
<th></th>
</tr>
<tr ng-if="channels.length === 0">
<td colspan="3">
<p class="text-center text-danger">No channels found. Click the <span class="fa fa-plus"></span> to create a channel.</p>
</td>
</tr>
<tr ng-repeat="x in channels" ng-click="selectChannel($index)" style="cursor: pointer;">
<td>{{x.number}}</td>
<td>{{x.name}}</td>
<td class="text-right">
<button class="btn btn-sm" ng-click="removeChannel(x); $event.stopPropagation()">
<span class="fa fa-trash"></span>
</button>
</td>
</tr>
</table>
</div>

View File

@ -0,0 +1,29 @@
<div>
<ul class="nav nav-tabs">
<li class="nav-item">
<span class="nav-link btn btn-link {{ selected === 'xmltv' ? 'active' : ''}}" ng-click="selected = 'xmltv'">
XMLTV
</span>
</li>
<li class="nav-item">
<span class="nav-link btn btn-link {{ selected === 'ffmpeg' ? 'active' : ''}}" ng-click="selected = 'ffmpeg'">
FFMPEG
</span>
</li>
<li class="nav-item">
<span class="nav-link btn btn-link {{ selected === 'plex' ? 'active' : ''}}" ng-click="selected = 'plex'">
Plex
</span>
</li>
<li class="nav-item">
<span class="nav-link btn btn-link {{ selected === 'hdhr' ? 'active' : ''}}" ng-click="selected = 'hdhr'">
HDHR
</span>
</li>
</ul>
<br />
<plex-settings ng-if="selected == 'plex'"></plex-settings>
<ffmpeg-settings ng-if="selected == 'ffmpeg'"></ffmpeg-settings>
<xmltv-settings ng-if="selected == 'xmltv'"></xmltv-settings>
<hdhr-settings ng-if="selected == 'hdhr'"></hdhr-settings>
</div>

96
web/services/plex.js Normal file
View File

@ -0,0 +1,96 @@
const Plex = require('../../src/plex')
module.exports = function () {
return {
login: (plex) => {
var client = new Plex({ protocol: plex.protocol, host: plex.host, port: plex.port })
return client.SignIn(plex.username, plex.password).then((res) => {
return client.Get('/').then((_res) => {
res.name = _res.friendlyName
return res
})
})
},
getLibrary: (server) => {
var client = new Plex(server)
return client.Get('/library/sections').then((res) => {
var sections = []
for (let i = 0, l = typeof res.Directory !== 'undefined' ? res.Directory.length : 0; i < l; i++)
if (res.Directory[i].type === 'movie' || res.Directory[i].type === 'show')
sections.push({
title: res.Directory[i].title,
key: `/library/sections/${res.Directory[i].key}/all`,
icon: `${server.protocol}://${server.host}:${server.port}${res.Directory[i].composite}?X-Plex-Token=${server.token}`,
type: res.Directory[i].type
})
return sections
})
},
getPlaylists: (server) => {
var client = new Plex(server)
return client.Get('/playlists').then((res) => {
var playlists = []
for (let i = 0, l = typeof res.Metadata !== 'undefined' ? res.Metadata.length : 0; i < l; i++)
if (res.Metadata[i].playlistType === 'video')
playlists.push({
title: res.Metadata[i].title,
key: res.Metadata[i].key,
icon: `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].composite}?X-Plex-Token=${server.token}`,
duration: res.Metadata[i].duration
})
return playlists
})
},
getNested: (server, key) => {
var client = new Plex(server)
return client.Get(key).then(function (res) {
var nested = []
for (let i = 0, l = typeof res.Metadata !== 'undefined' ? res.Metadata.length : 0; i < l; i++) {
var program = {
title: res.Metadata[i].title,
key: res.Metadata[i].key,
icon: `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].thumb}?X-Plex-Token=${server.token}`,
type: res.Metadata[i].type,
duration: res.Metadata[i].duration,
actualDuration: res.Metadata[i].duration,
durationStr: msToTime(res.Metadata[i].duration),
subtitle: res.Metadata[i].subtitle,
summary: res.Metadata[i].summary,
rating: res.Metadata[i].contentRating,
date: res.Metadata[i].originallyAvailableAt,
year: res.Metadata[i].year
}
if (program.type === 'episode') {
program.showTitle = res.Metadata[i].grandparentTitle
program.episode = res.Metadata[i].index
program.season = res.Metadata[i].parentIndex
program.icon = `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].grandparentThumb}?X-Plex-Token=${server.token}`
program.episodeIcon = `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].thumb}?X-Plex-Token=${server.token}`
program.seasonIcon = `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].parentThumb}?X-Plex-Token=${server.token}`
program.showIcon = `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].grandparentThumb}?X-Plex-Token=${server.token}`
program.file = `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].Media[0].Part[0].key}?X-Plex-Token=${server.token}`
} else if (program.type === 'movie') {
program.file = `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].Media[0].Part[0].key}?X-Plex-Token=${server.token}`
program.showTitle = res.Metadata[i].title
program.episode = 1
program.season = 1
}
nested.push(program)
}
return nested
})
}
}
}
function msToTime(duration) {
var milliseconds = parseInt((duration % 1000) / 100),
seconds = Math.floor((duration / 1000) % 60),
minutes = Math.floor((duration / (1000 * 60)) % 60),
hours = Math.floor((duration / (1000 * 60 * 60)) % 24);
hours = (hours < 10) ? "0" + hours : hours;
minutes = (minutes < 10) ? "0" + minutes : minutes;
seconds = (seconds < 10) ? "0" + seconds : seconds;
return hours + ":" + minutes + ":" + seconds + "." + milliseconds;
}

107
web/services/pseudotv.js Normal file
View File

@ -0,0 +1,107 @@
module.exports = function ($http) {
return {
getPlexServers: () => {
return $http.get('/api/plex-servers').then((d) => { return d.data })
},
addPlexServer: (plexServer) => {
return $http({
method: 'POST',
url: '/api/plex-servers',
data: plexServer,
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
removePlexServer: (serverId) => {
return $http({
method: 'DELETE',
url: '/api/plex-servers',
data: { _id: serverId },
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
getFfmpegSettings: () => {
return $http.get('/api/ffmpeg-settings').then((d) => { return d.data })
},
updateFfmpegSettings: (config) => {
return $http({
method: 'PUT',
url: '/api/ffmpeg-settings',
data: angular.toJson(config),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
resetFfmpegSettings: (config) => {
return $http({
method: 'POST',
url: '/api/ffmpeg-settings',
data: angular.toJson(config),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
getXmltvSettings: () => {
return $http.get('/api/xmltv-settings').then((d) => { return d.data })
},
updateXmltvSettings: (config) => {
return $http({
method: 'PUT',
url: '/api/xmltv-settings',
data: angular.toJson(config),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
resetXmltvSettings: (config) => {
return $http({
method: 'POST',
url: '/api/xmltv-settings',
data: angular.toJson(config),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
getHdhrSettings: () => {
return $http.get('/api/hdhr-settings').then((d) => { return d.data })
},
updateHdhrSettings: (config) => {
return $http({
method: 'PUT',
url: '/api/hdhr-settings',
data: angular.toJson(config),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
resetHdhrSettings: (config) => {
return $http({
method: 'POST',
url: '/api/hdhr-settings',
data: angular.toJson(config),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
getChannels: () => {
return $http.get('/api/channels').then((d) => { return d.data })
},
addChannel: (channel) => {
return $http({
method: 'POST',
url: '/api/channels',
data: angular.toJson(channel),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
updateChannel: (channel) => {
return $http({
method: 'PUT',
url: '/api/channels',
data: angular.toJson(channel),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
},
removeChannel: (channel) => {
return $http({
method: 'DELETE',
url: '/api/channels',
data: angular.toJson(channel),
headers: { 'Content-Type': 'application/json; charset=utf-8' }
}).then((d) => { return d.data })
}
}
}