0.0.5 - Bug fixes, subtitles, Google auth, channel icon overlay, dummy channel, other front end shit..
This commit is contained in:
parent
a13248d1e8
commit
bac4b191b2
54
README.md
54
README.md
@ -1,41 +1,63 @@
|
||||
# pseudotv-plex
|
||||
|
||||
Create your own Live TV channels from media on your Plex Server(s).
|
||||
PseudoTV is a Plex DVR plugin. It allows you to host your own fake live tv service by dynamically streaming media from your Plex servers(s). Your channels and settings are all manged throught the PseudoTV Web UI.
|
||||
|
||||
Simply create your Channels, add the PseudoTV tuner to Plex, and enjoy your fake TV service.
|
||||
|
||||
## How it works
|
||||
|
||||
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.
|
||||
PseudoTV will show up as a HDHomeRun device within Plex. When configuring your Plex Tuner, simply use the generatered `./.pseudotv/xmltv.xml` file for EPG data. PseudoTV will automatically refresh your Plex server's EPG data and channel mappings (if specified to do so in settings) when configuring channels via the Web UI. Ensure your FFMPEG path is set correctly via the Web UI, and enjoy!
|
||||
|
||||
## Features
|
||||
|
||||
- Docker support and prepackage binaries for Windows, Linux and Mac
|
||||
- Web UI for channel configuration and app settings
|
||||
- Select media across multiple Plex servers
|
||||
- Ability to auto update Plex EPG and channel mappings
|
||||
- Auto Update the xmltv.xml file at a set interval (in hours). You can also set the amount EPG cache (in hours).
|
||||
- Continuous playback support
|
||||
- Commercial support
|
||||
- Docker and prepackage binaries for Windows, Linux and Mac
|
||||
- Media track selection (video, audio, subtitle)
|
||||
- Subtitle Support (some subtitle formats may cause a delay when starting an ffmpeg session)
|
||||
- Internal Subs Supported
|
||||
- ASS (slow, I would avoid unless you got a bitchin cpu)
|
||||
- SRT (slow, I would avoid unless you got a bitchin cpu)
|
||||
- PGS (fast)
|
||||
- External Subs Supported
|
||||
- ASS (moderate)
|
||||
- SRT (moderate)
|
||||
- Ability to overlay channel icon over stream
|
||||
- Auto deinterlace any Plex media not marked `"scanType": "progressive"`
|
||||
|
||||
## 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.
|
||||
## Recent Bug Fixes and Notes
|
||||
- Removed FFPROBE requirment. Use Plex API for stream selection
|
||||
- Fixed issue with bulk imports fucking up season, episode order
|
||||
- Fixed an issue where Safari (and probably other browsers) couldn't load the web UI fully.
|
||||
- Plex accounts linked to google, facebook, etc can now sign in
|
||||
- PseudoTV will now host a dummy channel (Channel 1) when no channels configured. This makes setup a bit easier, no longer have to create a channel first..
|
||||
- No longer required to specify host address. I'm a fucking idiot and made shit more complicated than it needed to be. `channels.m3u` and `lineup.json` will now generate URLs based on the incoming http request.
|
||||
- Removed --host, and --xmltv arguments altogether
|
||||
- Added channel/app info to ts stream
|
||||
|
||||
## Useful Tips
|
||||
|
||||
- Internal SRT/ASS subtitle may cause a delay when starting stream
|
||||
- Utilize your hardware accelerated encoders, or use mpeg2 instead of h264 by changing the default video encoder in FFMPEG settings. *Note that some encoders may not be capable of handling every transcoding scenario, libx264 and mpeg2video seem to be the most stable.*
|
||||
- Intel Quick Sync: `h265_qsv`, `mpeg2_qsv`
|
||||
- NVIDIA GPU: `h264_nvenc`
|
||||
- MPEG2 `mpeg2video`
|
||||
- H264 `libx264` (default)
|
||||
- Host your own images for channel icons, program icons, etc.. Simply add your image to `.pseudotv/images` and reference them via `http://pseudotv-ip:8000/images/myImage.png`
|
||||
|
||||
## Installation
|
||||
|
||||
*Please delete your old `.pseudotv` directory before using the new version. I'm sorry but it'd take more effort than its worth to convert the old databases..*
|
||||
|
||||
Unless your are using the Docker image, you must download and install **ffmpeg** to your system and set the correct path in the PseudoTV Web UI.
|
||||
|
||||
By default, pseudotv will create the directory `.pseudotv` wherever pseudotv is launched from. Your `xmltv.xml` file and config databases are stored here. An M3U can also be downloaded via the Web UI (useful if using xTeVe).
|
||||
|
||||
**Do not use the Web UI XMLTV 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). Use the local file path to xmltv.xml**
|
||||
**Do not use the Web UI XMLTV 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). Use the local file path to `.pseudotv/xmltv.xml`**
|
||||
|
||||
#### Binary Release
|
||||
[Download](https://gitlab.com/DEFENDORe/pseudotv-plex/-/releases) and run the PseudoTV executable (argument defaults below)
|
||||
```
|
||||
./pseudotv-win.exe --host 127.0.0.1 --port 8000 --database ./pseudotv --xmltv ./pseudotv/xmltv.xml
|
||||
./pseudotv-win.exe --port 8000 --database ./pseudotv
|
||||
```
|
||||
|
||||
#### Docker Image
|
||||
@ -55,8 +77,6 @@ npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Development
|
||||
Building Binaries: (uses `babel` and `pkg`)
|
||||
```
|
||||
|
||||
62
index.js
62
index.js
@ -11,23 +11,15 @@ const HDHR = require('./src/hdhr')
|
||||
const xmltv = require('./src/xmltv')
|
||||
const Plex = require('./src/plex')
|
||||
|
||||
const helperFuncs = require('./src/helperFuncs')
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
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 (!fs.existsSync(process.env.DATABASE))
|
||||
fs.mkdirSync(process.env.DATABASE)
|
||||
@ -51,18 +43,11 @@ let xmltvInterval = {
|
||||
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])
|
||||
await plex.GetDVRS().then(async (dvrs) => { // Refresh guide and channel mappings
|
||||
await plex.GetDVRS().then(async (dvrs) => { // Refresh guide and channel mappings
|
||||
if (plexServers[i].arGuide)
|
||||
plex.RefreshGuide(dvrs).then(() => { }, (err) => { console.error(err, i) })
|
||||
if (plexServers[i].arChannels)
|
||||
if (plexServers[i].arChannels && channels.length !== 0)
|
||||
plex.RefreshChannels(channels, dvrs).then(() => { }, (err) => { console.error(err, i) })
|
||||
})
|
||||
}
|
||||
@ -97,7 +82,7 @@ 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}`)
|
||||
console.log(`HTTP server running on port: http://*:${process.env.PORT}`)
|
||||
let hdhrSettings = db['hdhr-settings'].find()[0]
|
||||
if (hdhrSettings.autoDiscovery === true)
|
||||
hdhr.ssdp.start()
|
||||
@ -105,34 +90,37 @@ app.listen(process.env.PORT, () => {
|
||||
|
||||
function initDB(db) {
|
||||
let ffmpegSettings = db['ffmpeg-settings'].find()
|
||||
if (!fs.existsSync(process.env.DATABASE + '/resources/font.ttf')) {
|
||||
let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/font.ttf')))
|
||||
fs.writeFileSync(process.env.DATABASE + '/font.ttf', data)
|
||||
}
|
||||
|
||||
if (ffmpegSettings.length === 0) {
|
||||
db['ffmpeg-settings'].save({
|
||||
ffmpegPath: "/usr/bin/ffmpeg",
|
||||
ffprobePath: "/usr/bin/ffprobe",
|
||||
ffmpegPath: '/usr/bin/ffmpeg',
|
||||
offset: 0,
|
||||
threads: '4',
|
||||
videoEncoder: 'mpeg2video',
|
||||
threads: 4,
|
||||
videoEncoder: 'libx264',
|
||||
videoResolution: '1280x720',
|
||||
videoFrameRate: '30',
|
||||
videoBitrate: '10000k',
|
||||
audioBitrate: '192k',
|
||||
audioChannels: '2',
|
||||
audioRate: '48000',
|
||||
bufSize: '1000k',
|
||||
videoFrameRate: 30,
|
||||
videoBitrate: 10000,
|
||||
audioBitrate: 192,
|
||||
audioChannels: 2,
|
||||
audioRate: 48000,
|
||||
bufSize: 1000,
|
||||
audioEncoder: 'ac3',
|
||||
preferAudioLanguage: 'false',
|
||||
audioLanguage: 'eng',
|
||||
deinterlace: true,
|
||||
logFfmpeg: true,
|
||||
deinterlace: false,
|
||||
logFfmpeg: false,
|
||||
args: `-threads 4
|
||||
-ss STARTTIME
|
||||
-t DURATION
|
||||
-re
|
||||
-i INPUTFILE
|
||||
-vf yadif
|
||||
-map 0:v
|
||||
-t DURATION
|
||||
-map VIDEOSTREAM
|
||||
-map AUDIOSTREAM
|
||||
-c:v mpeg2video
|
||||
-c:v libx264
|
||||
-c:a ac3
|
||||
-ac 2
|
||||
-ar 48000
|
||||
@ -145,8 +133,12 @@ function initDB(db) {
|
||||
-minrate:v 10000k
|
||||
-maxrate:v 10000k
|
||||
-bufsize:v 1000k
|
||||
-metadata service_provider="PseudoTV"
|
||||
-metadata CHANNELNAME
|
||||
-f mpegts
|
||||
-output_ts_offset TSOFFSET
|
||||
-muxdelay 0
|
||||
-muxpreload 0
|
||||
OUTPUTFILE`
|
||||
})
|
||||
}
|
||||
@ -155,7 +147,7 @@ OUTPUTFILE`
|
||||
db['xmltv-settings'].save({
|
||||
cache: 12,
|
||||
refresh: 4,
|
||||
file: process.env.XMLTV
|
||||
file: `${process.env.DATABASE}/xmltv.xml`
|
||||
})
|
||||
}
|
||||
let hdhrSettings = db['hdhr-settings'].find()
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
"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",
|
||||
"package": "copyfiles ./web/public/**/* ./dist && copyfiles ./resources/**/* ./dist && pkg . --out-path bin",
|
||||
"clean": "del-cli --force ./bin ./dist ./.pseudotv ./web/public/bundle.js"
|
||||
},
|
||||
"author": "Dan Ferguson",
|
||||
@ -27,7 +27,7 @@
|
||||
},
|
||||
"bin": "dist/index.js",
|
||||
"pkg": {
|
||||
"assets": "dist/web/public/**/*",
|
||||
"assets": ["dist/web/public/**/*","dist/resources/**/*"],
|
||||
"targets": ["x86", "x64", "linux", "macos", "windows"]
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
BIN
resources/font.ttf
Normal file
BIN
resources/font.ttf
Normal file
Binary file not shown.
40
src/api.js
40
src/api.js
@ -64,31 +64,29 @@ function api(db, xmltvInterval) {
|
||||
router.post('/api/ffmpeg-settings', (req, res) => { // RESET
|
||||
db['ffmpeg-settings'].update({ _id: req.body._id }, {
|
||||
ffmpegPath: req.body.ffmpegPath,
|
||||
ffprobePath: req.body.ffprobePath,
|
||||
offset: 0,
|
||||
threads: '4',
|
||||
videoEncoder: 'mpeg2video',
|
||||
threads: 4,
|
||||
videoEncoder: 'libx264',
|
||||
videoResolution: '1280x720',
|
||||
videoFrameRate: '30',
|
||||
videoBitrate: '10000k',
|
||||
audioBitrate: '192k',
|
||||
audioChannels: '2',
|
||||
audioRate: '48000',
|
||||
bufSize: '1000k',
|
||||
videoFrameRate: 30,
|
||||
videoBitrate: 10000,
|
||||
audioBitrate: 192,
|
||||
audioChannels: 2,
|
||||
audioRate: 48000,
|
||||
bufSize: 1000,
|
||||
audioEncoder: 'ac3',
|
||||
preferAudioLanguage: 'false',
|
||||
audioLanguage: 'eng',
|
||||
deinterlace: true,
|
||||
logFfmpeg: true,
|
||||
deinterlace: false,
|
||||
logFfmpeg: false,
|
||||
args: `-threads 4
|
||||
-ss STARTTIME
|
||||
-t DURATION
|
||||
-re
|
||||
-i INPUTFILE
|
||||
-vf yadif
|
||||
-map 0:v
|
||||
-t DURATION
|
||||
-map VIDEOSTREAM
|
||||
-map AUDIOSTREAM
|
||||
-c:v mpeg2video
|
||||
-c:v libx264
|
||||
-c:a ac3
|
||||
-ac 2
|
||||
-ar 48000
|
||||
@ -101,8 +99,12 @@ function api(db, xmltvInterval) {
|
||||
-minrate:v 10000k
|
||||
-maxrate:v 10000k
|
||||
-bufsize:v 1000k
|
||||
-metadata service_provider="PseudoTV"
|
||||
-metadata CHANNELNAME
|
||||
-f mpegts
|
||||
-output_ts_offset TSOFFSET
|
||||
-muxdelay 0
|
||||
-muxpreload 0
|
||||
OUTPUTFILE`
|
||||
})
|
||||
let ffmpeg = db['ffmpeg-settings'].find()[0]
|
||||
@ -128,7 +130,7 @@ OUTPUTFILE`
|
||||
_id: req.body._id,
|
||||
cache: 12,
|
||||
refresh: 4,
|
||||
file: process.env.XMLTV
|
||||
file: process.env.DATABASE + '/xmltv.xml'
|
||||
})
|
||||
var xmltv = db['xmltv-settings'].find()[0]
|
||||
res.send(xmltv)
|
||||
@ -171,7 +173,11 @@ OUTPUTFILE`
|
||||
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].name}\n`
|
||||
data += `http://${process.env.HOST}:${process.env.PORT}/video?channel=${channels[i].number}\n`
|
||||
data += `${req.protocol}://${req.get('host')}/video?channel=${channels[i].number}\n`
|
||||
}
|
||||
if (channels.length === 0) {
|
||||
data += `#EXTINF:0 tvg-id="1" tvg-name="PseudoTV" tvg-logo="",PseudoTV\n`
|
||||
data += `${req.protocol}://${req.get('host')}/setup\n`
|
||||
}
|
||||
res.send(data)
|
||||
})
|
||||
|
||||
137
src/ffmpeg.js
137
src/ffmpeg.js
@ -1,14 +1,15 @@
|
||||
const spawn = require('child_process').spawn
|
||||
var events = require('events')
|
||||
const events = require('events')
|
||||
const fs = require('fs')
|
||||
|
||||
class ffmpeg extends events.EventEmitter {
|
||||
constructor(opts) {
|
||||
class FFMPEG extends events.EventEmitter {
|
||||
constructor(opts, channel) {
|
||||
super()
|
||||
this.offset = 0
|
||||
this.args = []
|
||||
this.opts = opts
|
||||
this.channel = channel
|
||||
this.ffmpegPath = opts.ffmpegPath
|
||||
this.ffprobePath = opts.ffprobePath
|
||||
let lines = opts.args.split('\n')
|
||||
for (let i = 0, l = lines.length; i < l; i++) {
|
||||
let x = lines[i].indexOf(' ')
|
||||
@ -20,46 +21,130 @@ class ffmpeg extends events.EventEmitter {
|
||||
}
|
||||
}
|
||||
}
|
||||
getStreams(file) {
|
||||
// This is used to generate ass subtitles from text subs to be used with the ass filter in ffmpeg.
|
||||
createSubsFromStream(file, startTime, duration, streamIndex, output) {
|
||||
if (process.env.DEBUG) console.log('Generating .ass subtitles')
|
||||
let exe = spawn(this.ffmpegPath, [
|
||||
'-threads', this.opts.threads,
|
||||
'-ss', startTime,
|
||||
'-i', file,
|
||||
'-t', duration,
|
||||
'-map', `0:${streamIndex}`,
|
||||
'-f', 'ass',
|
||||
output
|
||||
])
|
||||
return new Promise((resolve, reject) => {
|
||||
let ffprobe = spawn(this.ffprobePath, [ '-v', 'quiet', '-show_streams', '-of', 'json', file ])
|
||||
let str = ""
|
||||
ffprobe.stdout.on('data', (chunk) => {
|
||||
str += chunk
|
||||
})
|
||||
ffprobe.on('close', () => {
|
||||
resolve(str)
|
||||
if (this.opts.logFfmpeg) {
|
||||
exe.stderr.on('data', (chunk) => {
|
||||
process.stderr.write(chunk)
|
||||
})
|
||||
}
|
||||
exe.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
if (process.env.DEBUG) console.log('Successfully generated .ass subtitles')
|
||||
resolve()
|
||||
} else {
|
||||
console.log('Failed generating .ass subtitles.')
|
||||
reject()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
async spawn(lineupItem) {
|
||||
let audioIndex = -1
|
||||
if (this.opts.preferAudioLanguage === 'true') {
|
||||
let streams = JSON.parse(await this.getStreams(lineupItem.file)).streams
|
||||
for (let i = 0, l = streams.length; i < l; i++) {
|
||||
if (streams[i].codec_type === 'audio') {
|
||||
if (streams[i].tags.language === this.opts.audioLanguage) {
|
||||
audioIndex = i
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let videoIndex = lineupItem.opts.videoIndex
|
||||
let audioIndex = lineupItem.opts.audioIndex
|
||||
let subtitleIndex = lineupItem.opts.subtitleIndex
|
||||
let uniqSubFileName = Date.now().valueOf().toString()
|
||||
|
||||
for (let i = 0, l = lineupItem.streams.length; i < l; i++) {
|
||||
if (videoIndex === '-1' && lineupItem.streams[i].streamType === 1)
|
||||
if (lineupItem.streams[i].default)
|
||||
videoIndex = i
|
||||
if (audioIndex === '-1' && lineupItem.streams[i].streamType === 2)
|
||||
if (lineupItem.streams[i].default || lineupItem.streams[i].selected)
|
||||
audioIndex = i
|
||||
if (subtitleIndex === '-1' && lineupItem.streams[i].streamType === 3)
|
||||
if (lineupItem.streams[i].default || lineupItem.streams[i].forced)
|
||||
subtitleIndex = i
|
||||
}
|
||||
|
||||
// if for some reason we didn't find a default track, let ffmpeg decide..
|
||||
if (videoIndex === '-1')
|
||||
videoIndex = 'v'
|
||||
if (audioIndex === '-1')
|
||||
audioIndex === 'a'
|
||||
|
||||
let sub = (subtitleIndex === '-1' || subtitleIndex === '-2') ? null : lineupItem.streams[subtitleIndex]
|
||||
|
||||
let tmpargs = JSON.parse(JSON.stringify(this.args))
|
||||
let startTime = tmpargs.indexOf('STARTTIME')
|
||||
let dur = tmpargs.indexOf('DURATION')
|
||||
let input = tmpargs.indexOf('INPUTFILE')
|
||||
let vidStream = tmpargs.indexOf('VIDEOSTREAM')
|
||||
let output = tmpargs.indexOf('OUTPUTFILE')
|
||||
let tsoffset = tmpargs.indexOf('TSOFFSET')
|
||||
let audStream = tmpargs.indexOf('AUDIOSTREAM')
|
||||
let chanName = tmpargs.indexOf('CHANNELNAME')
|
||||
|
||||
tmpargs[startTime] = lineupItem.start / 1000
|
||||
tmpargs[dur] = lineupItem.duration / 1000
|
||||
tmpargs[input] = lineupItem.file
|
||||
tmpargs[audStream] = `0:${audioIndex === -1 ? 'a' : audioIndex}`
|
||||
tmpargs[audStream] = `0:${audioIndex}`
|
||||
tmpargs[chanName] = `service_name="${this.channel.name}"`
|
||||
tmpargs[tsoffset] = this.offset
|
||||
tmpargs[output] = 'pipe:1'
|
||||
|
||||
let iconOverlay = `[0:${videoIndex}]null`
|
||||
let deinterlace = 'null'
|
||||
let posAry = [ '20:20', 'W-w-20:20', '20:H-h-20', 'W-w-20:H-h-20'] // top-left, top-right, bottom-left, bottom-right (with 20px padding)
|
||||
let icnDur = ''
|
||||
if (this.channel.iconDuration > 0)
|
||||
icnDur = `:enable='between(t,0,${this.channel.iconDuration})'`
|
||||
if (this.channel.icon !== '' && this.channel.overlayIcon && lineupItem.type === 'program') {
|
||||
iconOverlay = `[1:v]scale=${this.channel.iconWidth}:-1[icn];[0:${videoIndex}][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}`
|
||||
if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled')
|
||||
}
|
||||
|
||||
if (videoIndex !== 'v') {
|
||||
if (typeof lineupItem.streams[videoIndex].scanType === 'undefined' || lineupItem.streams[videoIndex].scanType !== 'progressive') {
|
||||
deinterlace = 'yadif'
|
||||
if (process.env.DEBUG) console.log('Deinterlacing Video')
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === null || lineupItem.type === 'commercial') { // No subs or icon overlays for Commercials
|
||||
tmpargs[vidStream] = '[v]'
|
||||
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace}[v]`)
|
||||
console.log("No Subtitles")
|
||||
} else if (sub.codec === 'pgs') { // If program has PGS subs
|
||||
tmpargs[vidStream] = '[v]'
|
||||
if (typeof sub.index === 'undefined') { // If external subs
|
||||
console.log("PGS SUBS (external) - Not implemented..")
|
||||
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace}[v]`)
|
||||
} else { // Otherwise, internal/embeded pgs subs
|
||||
console.log("PGS SUBS (embeded)")
|
||||
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v2];[v2]${deinterlace}[v1];[v1][0:${sub.index}]overlay[v]`)
|
||||
}
|
||||
} else if (sub.codec === 'srt' || sub.codec === 'ass') {
|
||||
tmpargs[vidStream] = '[v]'
|
||||
if (typeof sub.index === 'undefined') {
|
||||
console.log("SRT SUBS (external)")
|
||||
await this.createSubsFromStream(sub.key, lineupItem.start / 1000, lineupItem.duration / 1000, 0, `${process.env.DATABASE}/${uniqSubFileName}.ass`)
|
||||
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace},ass=${process.env.DATABASE}/${uniqSubFileName}.ass[v]`)
|
||||
} else {
|
||||
console.log("SRT SUBS (embeded) - This may take a few seconds..")
|
||||
await this.createSubsFromStream(lineupItem.file, lineupItem.start / 1000, lineupItem.duration / 1000, sub.index, `${process.env.DATABASE}/${uniqSubFileName}.ass`)
|
||||
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace},ass=${process.env.DATABASE}/${uniqSubFileName}.ass[v]`)
|
||||
}
|
||||
} else { // Can't do VobSub's as plex only hosts the .idx file, there is no access to the .sub file.. Who the fuck uses VobSubs anyways.. SRT/ASS FTW
|
||||
tmpargs[vidStream] = '[v]'
|
||||
tmpargs.splice(vidStream - 1, 0, "-filter_complex", `${iconOverlay}[v1];[v1]${deinterlace}[v]`)
|
||||
console.log("No Compatible Subtitles")
|
||||
}
|
||||
|
||||
if (this.channel.icon !== '' && this.channel.overlayIcon && lineupItem.type === 'program') // Add the channel icon to ffmpeg input if enabled
|
||||
tmpargs.splice(vidStream - 1, 0, '-i', this.channel.icon)
|
||||
|
||||
this.offset += lineupItem.duration / 1000
|
||||
this.ffmpeg = spawn(this.ffmpegPath, tmpargs)
|
||||
this.ffmpeg.stdout.on('data', (chunk) => {
|
||||
@ -71,6 +156,8 @@ class ffmpeg extends events.EventEmitter {
|
||||
})
|
||||
}
|
||||
this.ffmpeg.on('close', (code) => {
|
||||
if (fs.existsSync(`${process.env.DATABASE}/${uniqSubFileName}.ass`))
|
||||
fs.unlinkSync(`${process.env.DATABASE}/${uniqSubFileName}.ass`)
|
||||
if (code === null)
|
||||
this.emit('close', code)
|
||||
else if (code === 0)
|
||||
@ -84,4 +171,4 @@ class ffmpeg extends events.EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ffmpeg
|
||||
module.exports = FFMPEG
|
||||
50
src/ffmpegText.js
Normal file
50
src/ffmpegText.js
Normal file
@ -0,0 +1,50 @@
|
||||
const spawn = require('child_process').spawn
|
||||
const events = require('events')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
class FFMPEG_TEXT extends events.EventEmitter {
|
||||
constructor (opts, title, subtitle) {
|
||||
super()
|
||||
this.ffmpegPath = opts.ffmpegPath
|
||||
|
||||
this.args = [
|
||||
'-threads', opts.threads,
|
||||
'-f', 'lavfi',
|
||||
'-re',
|
||||
'-stream_loop', '-1',
|
||||
'-i', 'color=c=black:s=1280x720',
|
||||
'-f', 'lavfi',
|
||||
'-i', 'anullsrc',
|
||||
'-vf', `drawtext=fontfile=${process.env.DATABASE}/font.ttf:fontsize=30:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2:text='${title}',drawtext=fontfile=${process.env.DATABASE}/font.ttf:fontsize=20:fontcolor=white:x=(w-text_w)/2:y=(h+text_h+20)/2:text='${subtitle}'`,
|
||||
'-c:v', 'libx264',
|
||||
'-c:a', 'ac3',
|
||||
'-f', 'mpegts',
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
this.ffmpeg = spawn(opts.ffmpegPath, this.args)
|
||||
|
||||
this.ffmpeg.stdout.on('data', (chunk) => {
|
||||
this.emit('data', chunk)
|
||||
})
|
||||
|
||||
if (opts.logFfmpeg) {
|
||||
this.ffmpeg.stderr.on('data', (chunk) => {
|
||||
process.stderr.write(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
this.ffmpeg.on('close', (code) => {
|
||||
if (code === null)
|
||||
this.emit('close', code)
|
||||
else
|
||||
this.emit('error', { code: code, cmd: `${this.args.join(' ')}` })
|
||||
})
|
||||
}
|
||||
kill() {
|
||||
this.ffmpeg.kill()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FFMPEG_TEXT
|
||||
14
src/hdhr.js
14
src/hdhr.js
@ -21,14 +21,14 @@ function hdhr(db) {
|
||||
var router = express.Router()
|
||||
|
||||
router.get('/device.xml', (req, res) => {
|
||||
var device = getDevice(db)
|
||||
var device = getDevice(db, req.protocol + '://' + req.get('host'))
|
||||
res.header("Content-Type", "application/xml")
|
||||
var data = device.getXml()
|
||||
res.send(data)
|
||||
})
|
||||
|
||||
router.get('/discover.json', (req, res) => {
|
||||
var device = getDevice(db)
|
||||
var device = getDevice(db, req.protocol + '://' + req.get('host'))
|
||||
res.header("Content-Type", "application/json")
|
||||
res.send(JSON.stringify(device))
|
||||
})
|
||||
@ -48,14 +48,16 @@ function hdhr(db) {
|
||||
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}` })
|
||||
lineup.push({ GuideNumber: channels[i].number.toString(), GuideName: channels[i].name, URL: `${req.protocol}://${req.get('host')}/video?channel=${channels[i].number}` })
|
||||
if (lineup.length === 0)
|
||||
lineup.push({ GuideNumber: '1', GuideName: 'PseudoTV', URL: `${req.protocol}://${req.get('host')}/setup` })
|
||||
res.send(JSON.stringify(lineup))
|
||||
})
|
||||
|
||||
return { router: router, ssdp: server }
|
||||
}
|
||||
|
||||
function getDevice(db) {
|
||||
function getDevice(db, host) {
|
||||
let hdhrSettings = db['hdhr-settings'].find()[0]
|
||||
var device = {
|
||||
FriendlyName: "PseudoTV",
|
||||
@ -67,8 +69,8 @@ function getDevice(db) {
|
||||
FirmwareVersion: "20170930",
|
||||
DeviceID: 'PseudoTV',
|
||||
DeviceAuth: "",
|
||||
BaseURL: `http://${process.env.HOST}:${process.env.PORT}`,
|
||||
LineupURL: `http://${process.env.HOST}:${process.env.PORT}/lineup.json`
|
||||
BaseURL: `${host}`,
|
||||
LineupURL: `${host}/lineup.json`
|
||||
}
|
||||
device.getXml = () => {
|
||||
str =
|
||||
|
||||
@ -1,29 +1,6 @@
|
||||
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
|
||||
createLineup: createLineup
|
||||
}
|
||||
|
||||
function getCurrentProgramAndTimeElapsed(date, channel) {
|
||||
@ -46,7 +23,7 @@ function getCurrentProgramAndTimeElapsed(date, channel) {
|
||||
return { program: channel.programs[currentProgramIndex], timeElapsed: timeElapsed, programIndex: currentProgramIndex }
|
||||
}
|
||||
|
||||
function createProgramStreamTimeline(obj) {
|
||||
function createLineup(obj) {
|
||||
let timeElapsed = obj.timeElapsed
|
||||
let activeProgram = obj.program
|
||||
let lineup = []
|
||||
@ -64,15 +41,19 @@ function createProgramStreamTimeline(obj) {
|
||||
lineup.push({
|
||||
type: 'commercial',
|
||||
file: commercials[i][y].file,
|
||||
streams: commercials[i][y].streams,
|
||||
start: timeElapsed, // start time will be the time elapsed, cause this is the first video
|
||||
duration: commercials[i][y].duration - timeElapsed // duration set accordingly
|
||||
duration: commercials[i][y].duration - timeElapsed, // duration set accordingly
|
||||
opts: commercials[i][y].opts
|
||||
})
|
||||
} 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,
|
||||
streams: commercials[i][y].streams,
|
||||
start: 0,
|
||||
duration: commercials[i][y].duration
|
||||
duration: commercials[i][y].duration,
|
||||
opts: commercials[i][y].opts
|
||||
})
|
||||
} else { // Otherwise, this bitch has already been played.. Reduce the time elapsed by its duration
|
||||
timeElapsed -= commercials[i][y].duration
|
||||
@ -84,8 +65,10 @@ function createProgramStreamTimeline(obj) {
|
||||
lineup.push({
|
||||
type: 'program',
|
||||
file: activeProgram.file,
|
||||
streams: activeProgram.streams,
|
||||
start: progTimeElapsed + timeElapsed, // add the duration of already played program chunks to the timeElapsed
|
||||
duration: (programStartTimes[i + 1] - programStartTimes[i]) - timeElapsed
|
||||
duration: (programStartTimes[i + 1] - programStartTimes[i]) - timeElapsed,
|
||||
opts: activeProgram.opts
|
||||
})
|
||||
} else if (foundFirstVideo) {
|
||||
if (lineup[lineup.length - 1].type === 'program') { // merge consecutive programs..
|
||||
@ -94,8 +77,10 @@ function createProgramStreamTimeline(obj) {
|
||||
lineup.push({
|
||||
type: 'program',
|
||||
file: activeProgram.file,
|
||||
streams: activeProgram.streams,
|
||||
start: programStartTimes[i],
|
||||
duration: (programStartTimes[i + 1] - programStartTimes[i])
|
||||
duration: (programStartTimes[i + 1] - programStartTimes[i]),
|
||||
opts: activeProgram.opts
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -112,18 +112,18 @@ class Plex {
|
||||
})
|
||||
})
|
||||
}
|
||||
GetDVRS = async function () {
|
||||
async GetDVRS() {
|
||||
var result = await this.Get('/livetv/dvrs')
|
||||
var dvrs = result.Dvr
|
||||
dvrs = typeof dvrs === 'undefined' ? [] : dvrs
|
||||
return dvrs
|
||||
}
|
||||
RefreshGuide = async function (_dvrs) {
|
||||
async RefreshGuide(_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) {
|
||||
async RefreshChannels(channels, _dvrs) {
|
||||
var dvrs = typeof _dvrs !== 'undefined' ? _dvrs : await this.GetDVRS()
|
||||
var _channels = []
|
||||
let qs = {}
|
||||
|
||||
60
src/video.js
60
src/video.js
@ -1,12 +1,40 @@
|
||||
const express = require('express')
|
||||
const helperFuncs = require('./helperFuncs')
|
||||
const ffmpeg = require('./ffmpeg')
|
||||
const FFMPEG = require('./ffmpeg')
|
||||
const FFMPEG_TEXT = require('./ffmpegText')
|
||||
const fs = require('fs')
|
||||
|
||||
module.exports = { router: video }
|
||||
|
||||
function video(db) {
|
||||
var router = express.Router()
|
||||
|
||||
router.get('/setup', (req, res) => {
|
||||
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(`\r\nStream starting. Channel: 1 (PseudoTV)`)
|
||||
|
||||
let ffmpeg = new FFMPEG_TEXT(ffmpegSettings, 'PseudoTV', 'Configure your channels using the PseudoTV Web UI')
|
||||
|
||||
ffmpeg.on('data', (data) => { res.write(data) })
|
||||
|
||||
ffmpeg.on('error', (err) => {
|
||||
console.error("FFMPEG ERROR", err)
|
||||
res.status(500).send("FFMPEG ERROR")
|
||||
})
|
||||
|
||||
res.on('close', () => { // on HTTP close, kill ffmpeg
|
||||
ffmpeg.kill()
|
||||
console.log(`\r\nStream ended. Channel: 1 (PseudoTV)`)
|
||||
})
|
||||
})
|
||||
|
||||
router.get('/video', (req, res) => {
|
||||
// Check if channel queried is valid
|
||||
if (typeof req.query.channel === 'undefined') {
|
||||
@ -21,7 +49,8 @@ function video(db) {
|
||||
channel = channel[0]
|
||||
|
||||
// Get video lineup (array of video urls with calculated start times and durations.)
|
||||
let lineup = helperFuncs.getLineup(Date.now(), channel)
|
||||
let prog = helperFuncs.getCurrentProgramAndTimeElapsed(Date.now(), channel)
|
||||
let lineup = helperFuncs.createLineup(prog)
|
||||
let ffmpegSettings = db['ffmpeg-settings'].find()[0]
|
||||
|
||||
// Check if ffmpeg path is valid
|
||||
@ -31,26 +60,31 @@ function video(db) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Stream started. Channel: ${channel.number} (${channel.name})`)
|
||||
console.log(`\r\nStream starting. Channel: ${channel.number} (${channel.name})`)
|
||||
|
||||
let ffmpeg2 = new ffmpeg(ffmpegSettings) // Set the transcoder options
|
||||
let ffmpeg = new FFMPEG(ffmpegSettings, channel) // Set the transcoder options
|
||||
|
||||
ffmpeg2.on('data', (data) => { res.write(data) })
|
||||
ffmpeg.on('data', (data) => { res.write(data) })
|
||||
|
||||
ffmpeg2.on('error', (err) => { console.error("FFMPEG ERROR", err) })
|
||||
ffmpeg.on('error', (err) => {
|
||||
console.error("FFMPEG ERROR", err)
|
||||
res.status(500).send("FFMPEG ERROR")
|
||||
})
|
||||
|
||||
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
|
||||
ffmpeg.on('end', () => { // On finish transcode - END of program or commercial...
|
||||
if (lineup.length === 0) { // refresh the expired program/lineup
|
||||
prog = helperFuncs.getCurrentProgramAndTimeElapsed(Date.now(), channel)
|
||||
lineup = helperFuncs.createLineup(prog)
|
||||
}
|
||||
ffmpeg.spawn(lineup.shift(), prog.program) // Spawn the next ffmpeg process
|
||||
})
|
||||
|
||||
res.on('close', () => { // on HTTP close, kill ffmpeg
|
||||
ffmpeg2.kill()
|
||||
console.log(`Stream ended. Channel: ${channel.number} (${channel.name})`)
|
||||
ffmpeg.kill()
|
||||
console.log(`\r\nStream ended. Channel: ${channel.number} (${channel.name})`)
|
||||
})
|
||||
|
||||
ffmpeg2.spawn(lineup.shift()) // Spawn the ffmpeg process, fire this bitch up
|
||||
ffmpeg.spawn(lineup.shift(), prog.program) // Spawn the ffmpeg process, fire this bitch up
|
||||
|
||||
})
|
||||
return router
|
||||
|
||||
32
src/xmltv.js
32
src/xmltv.js
@ -12,9 +12,25 @@ function WriteXMLTV(channels, xmlSettings) {
|
||||
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)
|
||||
if (channels.length === 0) {
|
||||
_writeChannels(xw, [{ number: 1, name: "PseudoTV", icon: null }])
|
||||
let program = {
|
||||
program: {
|
||||
type: 'movie',
|
||||
title: 'No Channels Configured',
|
||||
summary: 'Configure your channels using the PseudoTV Web UI.'
|
||||
},
|
||||
channel: '1',
|
||||
start: date,
|
||||
stop: new Date(date.valueOf() + xmlSettings.cache * 60 * 60 * 1000)
|
||||
}
|
||||
_writeProgramme(xw, program)
|
||||
} else {
|
||||
_writeChannels(xw, channels)
|
||||
for (var i = 0; i < channels.length; i++)
|
||||
_writePrograms(xw, channels[i], date, xmlSettings.cache)
|
||||
}
|
||||
|
||||
_writeDocEnd(xw, ws)
|
||||
ws.close()
|
||||
})
|
||||
@ -79,7 +95,7 @@ function _writeProgramme(xw, program) {
|
||||
xw.startElement('title')
|
||||
xw.writeAttribute('lang', 'en')
|
||||
|
||||
if (program.program.type == 'episode') {
|
||||
if (program.program.type === 'episode') {
|
||||
xw.text(program.program.showTitle)
|
||||
xw.endElement()
|
||||
xw.writeRaw('\n <previously-shown/>')
|
||||
@ -98,9 +114,11 @@ function _writeProgramme(xw, program) {
|
||||
xw.endElement()
|
||||
}
|
||||
// Icon
|
||||
xw.startElement('icon')
|
||||
xw.writeAttribute('src', program.program.icon)
|
||||
xw.endElement()
|
||||
if (typeof program.program.icon !== 'undefined') {
|
||||
xw.startElement('icon')
|
||||
xw.writeAttribute('src', program.program.icon)
|
||||
xw.endElement()
|
||||
}
|
||||
// Desc
|
||||
xw.startElement('desc')
|
||||
xw.writeAttribute('lang', 'en')
|
||||
|
||||
@ -14,6 +14,9 @@ module.exports = function ($timeout) {
|
||||
scope.channel.programs = []
|
||||
scope.isNewChannel = true
|
||||
scope.channel.icon = ""
|
||||
scope.channel.iconWidth = 120
|
||||
scope.channel.iconDuration = 60
|
||||
scope.channel.iconPosition = "2"
|
||||
scope.channel.startTime = new Date()
|
||||
scope.channel.startTime.setMilliseconds(0)
|
||||
scope.channel.startTime.setSeconds(0)
|
||||
@ -154,7 +157,7 @@ module.exports = function ($timeout) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
function shuffle(array) {
|
||||
var currentIndex = array.length, temporaryValue, randomIndex
|
||||
let currentIndex = array.length, temporaryValue, randomIndex
|
||||
while (0 !== currentIndex) {
|
||||
randomIndex = Math.floor(Math.random() * currentIndex)
|
||||
currentIndex -= 1
|
||||
@ -164,6 +167,7 @@ module.exports = function ($timeout) {
|
||||
}
|
||||
return array
|
||||
}
|
||||
scope.updateChannelDuration = updateChannelDuration
|
||||
function updateChannelDuration() {
|
||||
scope.channel.duration = 0
|
||||
for (let i = 0, l = scope.channel.programs.length; i < l; i++) {
|
||||
@ -178,31 +182,30 @@ module.exports = function ($timeout) {
|
||||
scope.onDone()
|
||||
else {
|
||||
channelNumbers = []
|
||||
for (let i = 0, l = scope.channels.length; i < l; i++) {
|
||||
for (let i = 0, l = scope.channels.length; i < l; i++)
|
||||
channelNumbers.push(scope.channels[i].number)
|
||||
}
|
||||
// validate
|
||||
var now = new Date()
|
||||
if (typeof channel.number === "undefined" || channel.number === null || channel.number === "") {
|
||||
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
|
||||
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) {
|
||||
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) {
|
||||
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 === "") {
|
||||
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)) {
|
||||
else if (channel.icon !== "" && !validURL(channel.icon))
|
||||
scope.error.icon = "Please enter a valid image URL. Or leave blank."
|
||||
} else if (now < channel.startTime) {
|
||||
else if (channel.overlayIcon && !validURL(channel.icon))
|
||||
scope.error.icon = "Please enter a valid image URL. Cant overlay an invalid image."
|
||||
else if (now < channel.startTime)
|
||||
scope.error.startTime = "Start time must not be set in the future."
|
||||
} else if (channel.programs.length === 0) {
|
||||
else if (channel.programs.length === 0)
|
||||
scope.error.programs = "No programs have been selected. Select at least one program."
|
||||
} else {
|
||||
// DONE.
|
||||
else
|
||||
scope.onDone(JSON.parse(angular.toJson(channel)))
|
||||
}
|
||||
$timeout(() => { scope.error = {} }, 3500)
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,23 +31,27 @@
|
||||
-t DURATION
|
||||
-re
|
||||
-i INPUTFILE${ scope.settings.deinterlace ? `\n-vf yadif` : `` }
|
||||
-map 0:v
|
||||
-map VIDEOSTREAM
|
||||
-map AUDIOSTREAM
|
||||
-c:v ${ scope.settings.videoEncoder}
|
||||
-c:a ${ scope.settings.audioEncoder}
|
||||
-ac ${ scope.settings.audioChannels}
|
||||
-ar ${ scope.settings.audioRate}
|
||||
-b:a ${ scope.settings.audioBitrate}
|
||||
-b:v ${ scope.settings.videoBitrate}
|
||||
-s ${ scope.settings.videoResolution}
|
||||
-r ${ scope.settings.videoFrameRate}
|
||||
-c:v ${ scope.settings.videoEncoder }
|
||||
-c:a ${ scope.settings.audioEncoder }
|
||||
-ac ${ scope.settings.audioChannels }
|
||||
-ar ${ scope.settings.audioRate }
|
||||
-b:a ${ scope.settings.audioBitrate }k
|
||||
-b:v ${ scope.settings.videoBitrate }k
|
||||
-s ${ scope.settings.videoResolution }
|
||||
-r ${ scope.settings.videoFrameRate }
|
||||
-flags cgop+ilme
|
||||
-sc_threshold 1000000000
|
||||
-minrate:v ${ scope.settings.videoBitrate}
|
||||
-maxrate:v ${ scope.settings.videoBitrate}
|
||||
-bufsize:v ${ scope.settings.bufSize}
|
||||
-minrate:v ${ scope.settings.videoBitrate }k
|
||||
-maxrate:v ${ scope.settings.videoBitrate }k
|
||||
-bufsize:v ${ scope.settings.bufSize }k
|
||||
-metadata service_provider="PseudoTV"
|
||||
-metadata CHANNELNAME
|
||||
-f mpegts
|
||||
-output_ts_offset TSOFFSET
|
||||
-muxdelay 0
|
||||
-muxpreload 0
|
||||
OUTPUTFILE`
|
||||
|
||||
}
|
||||
|
||||
@ -15,12 +15,19 @@ module.exports = function (plex, pseudotv, $timeout) {
|
||||
updateLibrary(server)
|
||||
}
|
||||
scope._onFinish = (s) => {
|
||||
scope.onFinish(JSON.parse(angular.toJson(s)))
|
||||
scope.onFinish(s)
|
||||
scope.selection = []
|
||||
scope.visible = false
|
||||
}
|
||||
scope.selectItem = (item) => {
|
||||
scope.selection.push(JSON.parse(angular.toJson(item)))
|
||||
return new Promise((resolve, reject) => {
|
||||
$timeout(async () => {
|
||||
item.streams = await plex.getStreams(scope.plexServer, item.key)
|
||||
scope.selection.push(JSON.parse(angular.toJson(item)))
|
||||
scope.$apply()
|
||||
resolve()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
pseudotv.getPlexServers().then((servers) => {
|
||||
if (servers.length === 0) {
|
||||
@ -50,39 +57,47 @@ module.exports = function (plex, pseudotv, $timeout) {
|
||||
scope.getNested = (list) => {
|
||||
$timeout(async () => {
|
||||
if (typeof list.nested === 'undefined')
|
||||
list.nested = await plex.getNested(scope.plexServer, list.key)
|
||||
list.nested = await plex.getNested(scope.plexServer, list.key)
|
||||
list.collapse = !list.collapse
|
||||
scope.$apply()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
scope.selectSeason = async (season) => {
|
||||
$timeout(async () => {
|
||||
if (typeof season.nested === 'undefined') {
|
||||
season.nested = await plex.getNested(scope.plexServer, season.key)
|
||||
}
|
||||
for (let i = 0, l = season.nested.length; i < l; i++)
|
||||
scope.selectItem(season.nested[i])
|
||||
scope.$apply()
|
||||
}, 0)
|
||||
scope.selectSeason = (season) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
$timeout(async () => {
|
||||
if (typeof season.nested === 'undefined')
|
||||
season.nested = await plex.getNested(scope.plexServer, season.key)
|
||||
for (let i = 0, l = season.nested.length; i < l; i++)
|
||||
await scope.selectItem(season.nested[i])
|
||||
scope.$apply()
|
||||
resolve()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
scope.selectShow = async (show) => {
|
||||
$timeout(async () => {
|
||||
if (typeof show.nested === 'undefined')
|
||||
show.nested = await plex.getNested(scope.plexServer, show.key)
|
||||
for (let i = 0, l = show.nested.length; i < l; i++)
|
||||
await scope.selectSeason(show.nested[i])
|
||||
scope.$apply()
|
||||
}, 0)
|
||||
scope.selectShow = (show) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
$timeout(async () => {
|
||||
if (typeof show.nested === 'undefined')
|
||||
show.nested = await plex.getNested(scope.plexServer, show.key)
|
||||
for (let i = 0, l = show.nested.length; i < l; i++)
|
||||
await scope.selectSeason(show.nested[i])
|
||||
scope.$apply()
|
||||
resolve()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
scope.selectPlaylist = async (playlist) => {
|
||||
$timeout(async () => {
|
||||
if (typeof playlist.nested === 'undefined')
|
||||
playlist.nested = await plex.getNested(scope.plexServer, playlist.key)
|
||||
for (let i = 0, l = playlist.nested.length; i < l; i++)
|
||||
scope.selectItem(playlist.nested[i])
|
||||
scope.$apply()
|
||||
}, 0)
|
||||
return new Promise((resolve, reject) => {
|
||||
$timeout(async () => {
|
||||
if (typeof playlist.nested === 'undefined')
|
||||
playlist.nested = await plex.getNested(scope.plexServer, playlist.key)
|
||||
for (let i = 0, l = playlist.nested.length; i < l; i++)
|
||||
await scope.selectItem(playlist.nested[i])
|
||||
scope.$apply()
|
||||
resolve()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
scope.createShowIdentifier = (season, ep) => {
|
||||
return 'S' + (season.toString().padStart(2, '0')) + 'E' + (ep.toString().padStart(2, '0'))
|
||||
|
||||
@ -8,13 +8,11 @@ module.exports = function (plex, pseudotv, $timeout) {
|
||||
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.plex = { protocol: 'http', host: '127.0.0.1', port: '32400', 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)
|
||||
|
||||
@ -17,19 +17,18 @@ module.exports = function ($timeout) {
|
||||
}
|
||||
}
|
||||
scope.finished = (prog) => {
|
||||
if (prog.title === "") {
|
||||
if (prog.title === "")
|
||||
scope.error = { title: 'You must set a program title.' }
|
||||
} else if (prog.type === "episode" && prog.showTitle == "") {
|
||||
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)) {
|
||||
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) {
|
||||
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)) {
|
||||
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) {
|
||||
else if (prog.type === "episode" && prog.episode <= 0)
|
||||
scope.error = { episode: 'Episode number musat be greater than 0' }
|
||||
}
|
||||
|
||||
if (scope.error != null) {
|
||||
$timeout(() => {
|
||||
|
||||
@ -11,7 +11,13 @@
|
||||
|
||||
<body ng-app="myApp" style="min-width: 340px;">
|
||||
<div class="container">
|
||||
<h1>PseudoTV</h1>
|
||||
<h1>PseudoTV
|
||||
<small class="pull-right" style="padding: 5px;">
|
||||
<a href="https://gitlab.com/DEFENDORe/pseudotv-plex">
|
||||
<span class="fa fa-gitlab text-sm"></span>
|
||||
</a>
|
||||
</small>
|
||||
</h1>
|
||||
<a href="#!/channels">Channels</a> - <a href="#!/settings">Settings</a>
|
||||
<span class="pull-right">
|
||||
<span style="margin-right: 15px;">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<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-dialog modal-dialog-scrollable modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
@ -9,38 +9,80 @@
|
||||
</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"/>
|
||||
<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 for="channelIcon" class="small">Channel Icon</label>
|
||||
<div class="input-group mb-1">
|
||||
<input name="channelIcon" id="channelIcon" class="form-control form-control-sm" type="url" ng-model="channel.icon" />
|
||||
<div class="input-group-append">
|
||||
<div class="input-group-text" style="padding: 0">
|
||||
<label class="small" for="overlayIcon" style="margin-bottom: 4px;"> overlay over stream </label>
|
||||
<input id="overlayIcon" type="checkbox" ng-model="channel.overlayIcon">
|
||||
</div>
|
||||
</div>
|
||||
</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"/>
|
||||
<h6>Channel Icon Preview</h6>
|
||||
<img ng-if="channel.icon !== ''" ng-src="{{channel.icon}}" alt="{{channel.name}}" style="max-height: 120px;"/>
|
||||
<span ng-if="channel.icon === ''">{{channel.name}}</span>
|
||||
</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 ng-show="channel.overlayIcon">
|
||||
<h6>Icon Overlay Options
|
||||
<small>Not applicable to commercials.</small>
|
||||
</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<label for="channelIconPosition" class="small">Overlay Position</label>
|
||||
<div class="input-group mb-1">
|
||||
<select class="form-control form-control-sm" id="channelIconPosition" ng-model="channel.iconPosition">
|
||||
<option value="0">Top Left</option>
|
||||
<option value="1">Top Right</option>
|
||||
<option value="2">Bottom Left</option>
|
||||
<option value="3">Bottom Right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="channelIconWidth" class="small">Overlay Width (pixels)</label>
|
||||
<div class="input-group mb-1">
|
||||
<input id="channelIconWidth" class="form-control form-control-sm" type="number" ng-model="channel.iconWidth"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label for="channelIconDuration" class="small">Overlay Duration (seconds) (0 = permanent)</label>
|
||||
<div class="input-group mb-1">
|
||||
<input id="channelIconDuration" class="form-control form-control-sm" type="number" ng-model="channel.iconDuration" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
</div>
|
||||
</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>
|
||||
<hr />
|
||||
<div>
|
||||
|
||||
<h6>Programs
|
||||
<span class="small">Total: {{channel.programs.length}}</span>
|
||||
<span class="badge badge-dark" style="margin-left: 15px;" ng-show="channel.programs.length !== 0">Commercials</span>
|
||||
<button class="btn btn-sm btn-secondary" style="margin-left: 10px" ng-click="showShuffleOptions = !showShuffleOptions" ng-show="channel.programs.length !== 0">
|
||||
Shuffle / Sort <span class="fa {{ showShuffleOptions ? 'fa-chevron-down' : 'fa-chevron-right'}}"></span>
|
||||
<span class="badge badge-dark" style="margin-left: 15px;"
|
||||
ng-show="channel.programs.length !== 0">Commercials</span>
|
||||
<button class="btn btn-sm btn-secondary" style="margin-left: 10px"
|
||||
ng-click="showShuffleOptions = !showShuffleOptions"
|
||||
ng-show="channel.programs.length !== 0">
|
||||
Shuffle / Sort <span
|
||||
class="fa {{ showShuffleOptions ? 'fa-chevron-down' : 'fa-chevron-right'}}"></span>
|
||||
</button>
|
||||
<span class="pull-right">
|
||||
<span class="text-danger small">{{error.programs}}</span>
|
||||
@ -51,40 +93,43 @@
|
||||
</h6>
|
||||
<div ng-init="blockCount = 1; showShuffleOptions = false" ng-show="showShuffleOptions">
|
||||
<p class="text-center text-info small">"Block Shuffle" and "Sort TV Shows" will push any movies to the end of the channel.</p>
|
||||
<div class="input-group mb-1">
|
||||
<div class="input-group-prepend">
|
||||
<button class="btn btn-sm btn-warning" type="button" ng-click="blockShuffle(blockCount, randomizeBlockShuffle)">Block Shuffle</button>
|
||||
</div>
|
||||
<input type="number" class="form-control form-control-sm" placeholder="Desired number of consecutive TV shows." min="1" max="10" ng-model="blockCount">
|
||||
|
||||
<div class="input-group-append">
|
||||
<div class="input-group-text" style="padding: 0">
|
||||
<label class="small" for="randomizeBlockShuffle" style="margin-bottom: 4px;"> Randomize </label>
|
||||
<input id="randomizeBlockShuffle" type="checkbox" ng-model="randomizeBlockShuffle">
|
||||
<div class="row">
|
||||
<div class="col-md-6" style="padding: 5px;">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<input type="number" class="form-control form-control-sm" placeholder="Desired number of consecutive TV shows." min="1" max="10" ng-model="blockCount">
|
||||
</div>
|
||||
<div class="input-group-prepend">
|
||||
<div class="input-group-text" style="padding: 0;">
|
||||
<label class="small" for="randomizeBlockShuffle" style="margin-bottom: 4px;"> Randomize </label>
|
||||
<input id="randomizeBlockShuffle" type="checkbox" ng-model="randomizeBlockShuffle">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="blockShuffle(blockCount, randomizeBlockShuffle)">Block Shuffle</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group col-md-6" style="padding: 5px;">
|
||||
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="randomShuffle()">Random Shuffle</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group mb-1">
|
||||
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="randomShuffle()">Random Shuffle</button>
|
||||
</div>
|
||||
<div class="input-group mb-1">
|
||||
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="sortShows()">Sort TV Shows</button>
|
||||
</div>
|
||||
<div class="input-group mb-1">
|
||||
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="removeDuplicates()">Remove Duplicates</button>
|
||||
<div class="row">
|
||||
<div class="input-group col-md-6" style="padding: 5px;">
|
||||
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="sortShows()">Sort TV Shows</button>
|
||||
</div>
|
||||
<div class="input-group col-md-6" style="padding: 5px;">
|
||||
<button class="btn btn-sm btn-warning form-control form-control-sm" type="button" ng-click="removeDuplicates()">Remove Duplicates</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div ng-if="channel.programs.length === 0">
|
||||
<div class="small">Add programs to this channel by selecting media from your Plex library
|
||||
</div>
|
||||
<br />
|
||||
<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;">
|
||||
<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); updateChannelDuration()" dnd-effect-allowed="move">
|
||||
<div class="small" style="width: 180px; margin-right: 5px;">
|
||||
<div class="text-success">{{x.start.toLocaleString()}}</div>
|
||||
<div class="text-danger">{{x.stop.toLocaleString()}}</div>
|
||||
</div>
|
||||
@ -94,8 +139,7 @@
|
||||
<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>
|
||||
<span class="flex-pull-right btn fa fa-trash" ng-click="removeItem($index); $event.stopPropagation()"></span>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -10,18 +10,16 @@
|
||||
</h5>
|
||||
<h6>FFMPEG Executable Path (eg: C:\ffmpeg\bin\ffmpeg.exe || /usr/bin/ffmpeg)</h6>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.ffmpegPath"/>
|
||||
<h6>FFPROBE Executable Path (eg: C:\ffmpeg\bin\ffprobe.exe || /usr/bin/ffprobe)</h6>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.ffprobePath" ng-disabled="settings.preferAudioLanguage === 'false'"/>
|
||||
<hr/>
|
||||
<h6>Miscellaneous Options</h6>
|
||||
<div class="row">
|
||||
<div class="col-sm-4">
|
||||
<label>Threads</label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.threads" ng-change="createArgString()"/>
|
||||
<input type="number" class="form-control form-control-sm" ng-model="settings.threads" ng-change="createArgString()"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label>Buffer Size</label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.bufSize" ng-change="createArgString()"/>
|
||||
<label>Buffer Size (k)</label>
|
||||
<input type="number" class="form-control form-control-sm" ng-model="settings.bufSize" ng-change="createArgString()"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label>Log FFMPEG</label><br/>
|
||||
@ -34,14 +32,12 @@
|
||||
<h6>Video Options</h6>
|
||||
<label>Video Encoder</label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.videoEncoder" ng-change="createArgString()"/>
|
||||
<label>Video Bitrate</label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.videoBitrate" ng-change="createArgString()"/>
|
||||
<label>Video Bitrate (k)</label>
|
||||
<input type="number" class="form-control form-control-sm" ng-model="settings.videoBitrate" ng-change="createArgString()"/>
|
||||
<label>Video Resolution</label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.videoResolution" ng-change="createArgString()"/>
|
||||
<label>Video Framerate</label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.videoFrameRate" ng-change="createArgString()"/>
|
||||
<label>Deinterlace</label><br/>
|
||||
<input id="deinterlace" type="checkbox" ng-model="settings.deinterlace" ng-change="createArgString()"/> <label for="deinterlace">Deinterlace Video</label>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<h6>Audio Options</h6>
|
||||
@ -49,14 +45,10 @@
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.audioEncoder" ng-change="createArgString()"/>
|
||||
<label>Audio Channels</label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.audioChannels" ng-change="createArgString()"/>
|
||||
<label>Audio Bitrate</label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.audioBitrate" ng-change="createArgString()"/>
|
||||
<label>Audio Bitrate (k)</label>
|
||||
<input type="number" class="form-control form-control-sm" ng-model="settings.audioBitrate" ng-change="createArgString()"/>
|
||||
<label>Audio Rate</label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.audioRate" ng-change="createArgString()"/>
|
||||
<label>Audio Stream Preference</label><br/>
|
||||
<input id="preferAudioLanguage1" type="radio" ng-model="settings.preferAudioLanguage" value="false"/> <label for="preferAudioLanguage1">Default track</label>
|
||||
<input id="preferAudioLanguage2" type="radio" ng-model="settings.preferAudioLanguage" value="true"/> <label for="preferAudioLanguage2">Prefer Select Language <small>(uses ffprobe)</small></label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.audioLanguage" ng-disabled="settings.preferAudioLanguage === 'false'"/>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<div>
|
||||
<h5>HDHR Settings
|
||||
|
||||
<button class="pull-right btn btn-sm btn-success" style="margin-left: 5px;" ng-click="updateSettings(settings)">
|
||||
Update
|
||||
</button>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<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-dialog modal-dialog-scrollable modal-xl" role="document">
|
||||
<div class="modal-content" ng-if="noServers">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Plex Library</h5>
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
<form>
|
||||
<h6>Add a Plex Server
|
||||
<span class="pull-right text-danger">{{error}}</span>
|
||||
<span class="pull-right text-info">{{ isProcessing ? 'You have 2 minutes to sign into your Plex Account.' : ''}}</span>
|
||||
</h6>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-2">
|
||||
@ -24,14 +25,6 @@
|
||||
<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">
|
||||
@ -48,14 +41,13 @@
|
||||
<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"/>
|
||||
<input class="btn btn-sm btn-success" type="submit" ng-click="addPlexServer(plex)" ng-disabled="isProcessing" value="Sign In/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>
|
||||
<b>WARNING - Do not check "Auto Map Channels" unless the PseudoTV tuner is added to this specific Plex server.</b>
|
||||
</p>
|
||||
</div>
|
||||
<table class="table">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<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-dialog modal-dialog-scrollable modal-xl" role="document">
|
||||
<div class="modal-content">
|
||||
<div>
|
||||
<div class="modal-header">
|
||||
@ -76,12 +76,42 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h6>Streams</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<label for="videoStream">Video Track</label><br/>
|
||||
<select class="form-control form-control-sm" ng-model="program.opts.videoIndex">
|
||||
<option value="-1">Plex Default</option>
|
||||
<option ng-repeat="x in program.streams" ng-if="x.streamType === 1" value="{{x.index}}">{{x.displayTitle}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="audioStream">Audio Track</label><br/>
|
||||
<select class="form-control form-control-sm" ng-model="program.opts.audioIndex">
|
||||
<option value="-1">Plex Default</option>
|
||||
<option ng-repeat="x in program.streams" ng-if="x.streamType === 2" value="{{x.index}}">{{x.displayTitle}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="subtitleStream">Subtitle Track</label><br/>
|
||||
<select class="form-control form-control-sm" ng-model="program.opts.subtitleIndex">
|
||||
<option value="-2">No Subtitles</option>
|
||||
<option value="-1">Plex Default</option>
|
||||
<option ng-repeat="x in program.streams" ng-if="x.streamType === 3" value="{{$index}}">{{x.displayTitle}}</option>
|
||||
</select>
|
||||
</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 ng-show="program.commercials.length === 0">
|
||||
<p class="text-center text-info">Click the <span class="fa fa-plus"></span> to import "commercials" from your Plex server(s).</p>
|
||||
</div>
|
||||
<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}}
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
</h5>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>Number</th>
|
||||
<th width="120">Number</th>
|
||||
<th width="120">Icon</th>
|
||||
<th>Name</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@ -20,6 +21,10 @@
|
||||
</tr>
|
||||
<tr ng-repeat="x in channels" ng-click="selectChannel($index)" style="cursor: pointer;">
|
||||
<td>{{x.number}}</td>
|
||||
<td style="padding: 0" class="text-center">
|
||||
<img ng-if="x.icon !== ''" ng-src="{{x.icon}}" alt="{{x.name}}" style="max-height: 40px;"/>
|
||||
<div ng-if="x.icon === ''" style="padding-top: 14px;"><small>{{x.name}}</small></div>
|
||||
</td>
|
||||
<td>{{x.name}}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-sm" ng-click="removeChannel(x); $event.stopPropagation()">
|
||||
|
||||
@ -1,88 +1,146 @@
|
||||
const Plex = require('../../src/plex')
|
||||
module.exports = function () {
|
||||
module.exports = function ($http, $window, $interval) {
|
||||
return {
|
||||
login: (plex) => {
|
||||
login: async (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
|
||||
//const res = await client.SignIn(plex.username, plex.password)
|
||||
return new Promise((resolve, reject) => {
|
||||
$http({
|
||||
method: 'POST',
|
||||
url: 'https://plex.tv/api/v2/pins?strong=true',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Plex-Product': 'PseudoTV',
|
||||
'X-Plex-Version': 'Plex OAuth',
|
||||
'X-Plex-Client-Identifier': 'rg14zekk3pa5zp4safjwaa8z',
|
||||
'X-Plex-Model': 'Plex OAuth'
|
||||
}
|
||||
}).then((res) => {
|
||||
$window.open('https://app.plex.tv/auth/#!?clientID=rg14zekk3pa5zp4safjwaa8z&context[device][version]=Plex OAuth&context[device][model]=Plex OAuth&code=' + res.data.code + '&context[device][product]=Plex Web')
|
||||
let limit = 120000 // 2 minute time out limit
|
||||
let poll = 2500 // check every 2.5 seconds for token
|
||||
let interval = $interval(() => {
|
||||
$http({
|
||||
method: 'GET',
|
||||
url: `https://plex.tv/api/v2/pins/${res.data.id}`,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Plex-Product': 'PseudoTV',
|
||||
'X-Plex-Version': 'Plex OAuth',
|
||||
'X-Plex-Client-Identifier': 'rg14zekk3pa5zp4safjwaa8z',
|
||||
'X-Plex-Model': 'Plex OAuth'
|
||||
}
|
||||
}).then(async (r2) => {
|
||||
limit -= poll
|
||||
if (limit <= 0) {
|
||||
$interval.cancel(interval)
|
||||
reject('Timed Out. Failed to sign in a timely manner (2 mins)')
|
||||
}
|
||||
if (r2.data.authToken !== null) {
|
||||
$interval.cancel(interval)
|
||||
client._token = r2.data.authToken
|
||||
const _res = await client.Get('/')
|
||||
res.name = _res.friendlyName
|
||||
res.token = client._token
|
||||
resolve(res)
|
||||
}
|
||||
}, (err) => {
|
||||
$interval.cancel(interval)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
}, poll)
|
||||
}, (err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
getLibrary: (server) => {
|
||||
getLibrary: async (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
|
||||
})
|
||||
const res = await client.Get('/library/sections')
|
||||
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) => {
|
||||
getPlaylists: async (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++) {
|
||||
// Skip any videos (movie or episode) without a duration set...
|
||||
if (typeof res.Metadata[i].duration === 'undefined' && (res.Metadata[i].type === "episode" || res.Metadata[i].type === "movie"))
|
||||
continue
|
||||
if (res.Metadata[i].duration <= 0 && (res.Metadata[i].type === "episode" || res.Metadata[i].type === "movie"))
|
||||
continue
|
||||
var program = { // can be show, season or playlist too.
|
||||
const res = await client.Get('/playlists')
|
||||
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].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
|
||||
icon: `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].composite}?X-Plex-Token=${server.token}`,
|
||||
duration: res.Metadata[i].duration
|
||||
})
|
||||
return playlists
|
||||
},
|
||||
getStreams: async (server, key) => {
|
||||
var client = new Plex(server)
|
||||
return client.Get(key).then((res) => {
|
||||
let streams = res.Metadata[0].Media[0].Part[0].Stream
|
||||
for (let i = 0, l = streams.length; i < l; i++) {
|
||||
if (typeof streams[i].key !== 'undefined') {
|
||||
streams[i].key = `${server.protocol}://${server.host}:${server.port}${streams[i].key}?X-Plex-Token=${server.token}`
|
||||
}
|
||||
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
|
||||
return streams
|
||||
})
|
||||
},
|
||||
getNested: async (server, key) => {
|
||||
var client = new Plex(server)
|
||||
const res = await client.Get(key)
|
||||
var nested = []
|
||||
for (let i = 0, l = typeof res.Metadata !== 'undefined' ? res.Metadata.length : 0; i < l; i++) {
|
||||
// Skip any videos (movie or episode) without a duration set...
|
||||
if (typeof res.Metadata[i].duration === 'undefined' && (res.Metadata[i].type === "episode" || res.Metadata[i].type === "movie"))
|
||||
continue
|
||||
if (res.Metadata[i].duration <= 0 && (res.Metadata[i].type === "episode" || res.Metadata[i].type === "movie"))
|
||||
continue
|
||||
var program = {
|
||||
title: res.Metadata[i].title,
|
||||
key: res.Metadata[i].key,
|
||||
server: server,
|
||||
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.type === 'movie') {
|
||||
program.file = `${server.protocol}://${server.host}:${server.port}${res.Metadata[i].Media[0].Part[0].key}?X-Plex-Token=${server.token}`
|
||||
program.opts = { deinterlace: false, videoIndex: '-1', audioIndex: '-1', subtitleIndex: '-1' }
|
||||
}
|
||||
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}`
|
||||
}
|
||||
else if (program.type === 'movie') {
|
||||
program.showTitle = res.Metadata[i].title
|
||||
program.episode = 1
|
||||
program.season = 1
|
||||
}
|
||||
nested.push(program)
|
||||
}
|
||||
return nested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user