diff --git a/README.md b/README.md index dbade07..2ad677c 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,26 @@ Create Live TV/DVR channels from playlists in Plex. ### How it works -1. pseudotv-plex will scan Plex for playlists. Playlists with a **summary** starting with **pseudotv** will be fetched. -2. XMLTV and M3U files are generated from fetched playlists -3. Add the pseudotv (spoofed HDHomeRun) tuner into Plex, use the XMLTV file for guide information. -4. When tuning to a channel, a VLC session will be **spawned on demand**, hosting the channel's video stream. -5. Whenever a playlist change is detected, the M3U and XMLTV files will be rewritten +1. pseudotv-plex will scan Plex for playlists. Playlists with a summary starting with **pseudotv** will be fetched. +2. XMLTV and M3U files are generated from playlists, using metadata pulled from Plex. +3. Add the PseudoTV (spoofed HDHomeRun) tuner into Plex, use the XMLTV file as your EPG provider. +4. Watch your psudeo live tv channels ### Features -- Supports any video playlist in Plex, including Smart Playlists -- VLC sessions are spawned on demand. There will only ever be one VLC session per channel, no matter the number of viewers. -- VLC will **Direct Stream** if media is tagged **"optimizedForStreaming"** by Plex, otherwise VLC will transcode to h264/aac. -- EPG/Channels update automatically +- Plex transcoding (psuedotv-plex spoofs a Chrome Web Player, in order to receive a h264/aac stream from Plex) +- Live FFMPEG or VLC mpegts transmuxing +- Prebuffering (FFMPEG only) - transcodes entire video as fast as possible (not live stream) +- Auto update Plex DVR channel mappings and EPG. +- Web UI for manually triggering EPG updates + +**So far only tested in Windows. Should work cross platform. Docker container support coming soon.** + +**Critical Issues: Continuous playback is pretty much broken. I think the only way to get around that would be to transcode videos to a fixed framerate/bitrate. I really wish Plex documented their full API, there might be some parameters we can send to get such a stream..** + +## Prerequisites + +Install [NodeJS](https://nodejs.org/), and either [VLC](https://www.videolan.org/vlc/) or [FFMPEG](https://www.ffmpeg.org/) ## Install ``` @@ -25,9 +33,10 @@ npm install ``` ## Configure -### You must provide your Plex server details and the location of VLC -Edit the **config.yml** configuration file +You must provide your Plex server details and the location of VLC or FFMPEG + +### Edit the **`config.yml`** configuration file ## Start ``` @@ -36,30 +45,35 @@ npm start # Plex Playlist Setup -To assign a playlist as a channel, edit the summary if the playlist and write **pseudotv**. +To assign a playlist as a channel, edit the summary of the playlist in Plex and write **pseudotv** at the beginning. -Channel number and icon url are **optional** parameters. +**optional parameters:** *channelNumber*, *iconURL* and/or *shuffle*. In any order.. -Default channel number is the random Plex playlist ID - -![Playlist Setup](docs/playlist.png) +## Plex Playlist Example +### Title +``` +My Channel Name +``` +### Summary +``` +pseudotv 100 shuffle http://some.url/channel-icon.png +``` # Plex DVR Setup -Add the pseudotv-plex tuner to Plex. Use the **"Don't see your HDHomerun device? Enter its network address manually"** option if it doesn't show up automatically. +Add the PseudoTV tuner to Plex. Use the **"Don't see your HDHomerun device? Enter its network address manually"** option if it doesn't show up automatically. -Click the **continue** button after clicking **connect** +Use the generated XMLTV file as your EPG provider. -![DVR Setup - Step 1](docs/dvr1.png) +You wont be able to add the tuner to Plex until at least one channel has been generated. -Channels imported from Plex Playlists. **NOTE: If a new channel/playlist is added, you have to remove and re-setup the tuner in plex.** +# Plex Transcoding +When a channel is requested, pseudotv-plex will determine the current playing program and request a transcoded stream from Plex. When pseudotv-plex recieves the h264/acc stream,it is remuxed (using vlc or ffmpeg) into a mpegts container to be utilized by Plex DVR. -![DVR Setup - Step 2](docs/dvr2.png) +![DVR Guide](docs/transcode.png) -**Use the XMLTV option and select the pseudotv-plex generated xmltv.xml file** +# PseudoTV Web UI -![DVR Setup - Step 3](docs/dvr3.png) +Manually trigger EPG updates and view active channels using the Web UI. -Channels should automatically be matched. **Click continue** - -![DVR Setup - Step 4](docs/dvr4.png) \ No newline at end of file +![DVR Guide](docs/pseudotv.png) \ No newline at end of file diff --git a/docs/dvr1.png b/docs/dvr1.png deleted file mode 100644 index e0e803f..0000000 Binary files a/docs/dvr1.png and /dev/null differ diff --git a/docs/dvr2.png b/docs/dvr2.png deleted file mode 100644 index 15aab9f..0000000 Binary files a/docs/dvr2.png and /dev/null differ diff --git a/docs/dvr3.png b/docs/dvr3.png deleted file mode 100644 index a3e8b7d..0000000 Binary files a/docs/dvr3.png and /dev/null differ diff --git a/docs/dvr4.png b/docs/dvr4.png deleted file mode 100644 index 331014f..0000000 Binary files a/docs/dvr4.png and /dev/null differ diff --git a/docs/guide.png b/docs/guide.png index a9554c0..b850549 100644 Binary files a/docs/guide.png and b/docs/guide.png differ diff --git a/docs/playlist.png b/docs/playlist.png deleted file mode 100644 index e1dfba5..0000000 Binary files a/docs/playlist.png and /dev/null differ diff --git a/docs/pseudotv.png b/docs/pseudotv.png new file mode 100644 index 0000000..abd7bf7 Binary files /dev/null and b/docs/pseudotv.png differ diff --git a/docs/transcode.png b/docs/transcode.png new file mode 100644 index 0000000..f90be67 Binary files /dev/null and b/docs/transcode.png differ diff --git a/index.js b/index.js index 6b3c6f0..7dd9333 100644 --- a/index.js +++ b/index.js @@ -1,47 +1,37 @@ const express = require('express') -const fs = require('fs') -var path = require("path") -const config = require('config-yml') +var config = require('config-yml') -const hdhr = require('./src/hdhr') -const vlc = require('./src/vlc') +const plex = require('./src/plex') const xmltv = require('./src/xmltv') const m3u = require('./src/m3u') -const plex = require('./src/plex') +const ffmpeg = require('./src/ffmpeg') +const vlc = require('./src/vlc') +const hdhr = require('./src/hdhr')() +const pseudotv = require('./src/pseudotv') -// Plex does not update the playlists updatedAt property when the summary or title changes -var lastPlaylistUpdate = 0 // to watch for playlist updates -var channelsInfo = "" // to watch for playlist updates +plex(config.PLEX_OPTIONS, (result) => { + if (result.err) + return console.error("Failed to create plex client.", result.err) + var client = result.client -var refreshDate = new Date() // when the EPG will be updated -refreshDate.setHours(refreshDate.getHours() + config.EPG_REFRESH) + console.log("Plex authentication successful") -plex.PlexChannelScan((channels, lastUpdate, info) => { - console.log(`Generating EPG data(XMLTV) and channel playlists (M3U) from Plex. Channels: ${channels.length}`) - lastPlaylistUpdate = lastUpdate - channelsInfo = info - m3u.WriteM3U(channels, () => { console.log(`M3U File Location: ${path.resolve(config.M3U_OUTPUT)}`) }) - xmltv.WriteXMLTV(channels, () => { console.log(`XMLTV File Location: ${path.resolve(config.XMLTV_OUTPUT)}`) }) -}) - -setInterval(() => { - plex.PlexChannelScan((channels, lastUpdate, info) => { - var now = new Date() - // Update EPG whenever a psuedotv playlist is updated/added/removed, or at EPG_REFRESH interval - if (lastUpdate > lastPlaylistUpdate || channelsInfo !== info || now > refreshDate ) { - console.log(`Updating EPG data(XMLTV) and channel playlists (M3U) from Plex. Channels: ${channels.length}`) - m3u.WriteM3U(channels) - xmltv.UpdateXMLTV(channels) - lastPlaylistUpdate = lastUpdate - channelsInfo = info - refreshDate.setHours(refreshDate.getHours() + config.EPG_REFRESH) - }}) -}, config.PLEX_PLAYLIST_FETCH_TIMER * 1000) - -var app = express() -app.use(hdhr.router()) -app.use(vlc.router()) -app.listen(config.PORT, () => { - hdhr.start() - console.log(`Hosting VLC / HDHomeRun server at: http://${config.HOST}:${config.PORT}`) + var app = express() + if (config.MUXER.toLowerCase() === 'ffmpeg') + app.use(ffmpeg(client)) + else if (config.MUXER.toLowerCase() === 'vlc') + app.use(vlc(client)) + else + return console.error("Invalid MUXER specified in config.yml") + + if (config.HDHOMERUN_OPTIONS.ENABLED) + app.use(hdhr.router) + + app.use(pseudotv(client, xmltv, m3u)) + + app.listen(config.PORT, () => { + console.log(`pseudotv-plex: http://${config.HOST}:${config.PORT}`) + if (config.HDHOMERUN_OPTIONS.ENABLED && config.HDHOMERUN_OPTIONS.AUTODISCOVERY) + hdhr.ssdp.start() + }) }) diff --git a/package-lock.json b/package-lock.json index 8a78397..afe2e41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "psuedotv-plex", + "name": "pseudotv-plex", "version": "1.0.0", "lockfileVersion": 1, "requires": true, @@ -83,14 +83,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, - "basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "requires": { - "safe-buffer": "5.1.2" - } - }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -99,6 +91,14 @@ "tweetnacl": "^0.14.3" } }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -119,6 +119,13 @@ "qs": "6.7.0", "raw-body": "2.4.0", "type-is": "~1.6.17" + }, + "dependencies": { + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + } } }, "brace-expansion": { @@ -191,6 +198,13 @@ "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", "requires": { "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } } }, "content-type": { @@ -234,6 +248,11 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -331,6 +350,18 @@ "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" + }, + "dependencies": { + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } } }, "extend": { @@ -353,6 +384,11 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, "finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -451,6 +487,11 @@ "har-schema": "^2.0.0" } }, + "hoek": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.1.tgz", + "integrity": "sha512-QLg82fGkfnJ/4iy1xZ81/9SIJiq1NGFUMGs6ParyjBZr6jW2Ufj/snDqTHixNlHdPNwN2RLVD0Pi3igeK9+JfA==" + }, "hosted-git-info": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", @@ -466,6 +507,13 @@ "setprototypeof": "1.1.1", "statuses": ">= 1.5.0 < 2", "toidentifier": "1.0.0" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + } } }, "http-signature": { @@ -496,9 +544,9 @@ } }, "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "interpret": { "version": "1.2.0", @@ -543,11 +591,36 @@ "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" }, + "isemail": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/isemail/-/isemail-3.2.0.tgz", + "integrity": "sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==", + "requires": { + "punycode": "2.x.x" + } + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, + "joi": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-13.7.0.tgz", + "integrity": "sha512-xuY5VkHfeOYK3Hdi91ulocfuFopwgbSORmIwzcwHKESQhC7w1kD5jaVSPnqDxS2I8t3RZ9omCKAxNwXN5zG1/Q==", + "requires": { + "hoek": "5.x.x", + "isemail": "3.x.x", + "topo": "3.x.x" + }, + "dependencies": { + "hoek": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-5.0.4.tgz", + "integrity": "sha512-Alr4ZQgoMlnere5FZJsIyfIjORBqZll5POhDsF4q64dPuJR6rNxXdDxtHSQq8OXRurhmx+PWYEE8bXRROY8h0w==" + } + } + }, "js-yaml": { "version": "3.13.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", @@ -664,35 +737,30 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" }, - "morgan": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", - "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", - "requires": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.3.0", - "on-headers": "~1.0.2" - }, - "dependencies": { - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - } - } - }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, + "nan": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==" + }, "negotiator": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "node-expat": { + "version": "2.3.18", + "resolved": "https://registry.npmjs.org/node-expat/-/node-expat-2.3.18.tgz", + "integrity": "sha512-9dIrDxXePa9HSn+hhlAg1wXkvqOjxefEbMclGxk2cEnq/Y3U7Qo5HNNqeo3fQ4bVmLhcdt3YN1TZy7WMZy4MHw==", + "requires": { + "bindings": "^1.5.0", + "nan": "^2.13.2" + } + }, "node-ssdp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/node-ssdp/-/node-ssdp-4.0.0.tgz", @@ -749,11 +817,6 @@ "ee-first": "1.1.1" } }, - "on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" - }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -839,45 +902,6 @@ "pinkie": "^2.0.0" } }, - "plex-api": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/plex-api/-/plex-api-5.3.1.tgz", - "integrity": "sha512-WQVNOEqTCRx0/3oW5Orc+0OLAyQiDisCQ36mSVQHZOuuwXVj+l6WY9EksJzDXclp7Az3U8I/BNFDnopP1NxAFw==", - "requires": { - "plex-api-credentials": "3.0.1", - "plex-api-headers": "1.1.0", - "request": "^2.87.0", - "uuid": "2.0.2", - "xml2js": "0.4.16" - } - }, - "plex-api-credentials": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/plex-api-credentials/-/plex-api-credentials-3.0.1.tgz", - "integrity": "sha512-E0PdSVSqE5rmdEFNsIvFPDJQZPdBX7UR4sgkm9HF4V8VNbX0N4elASnMuoste8i9eTh4hCIqt761NQfzl45XnQ==", - "requires": { - "bluebird": "^3.3.5", - "plex-api-headers": "1.1.0", - "request-promise": "4.2.4", - "xml2js": "0.4.19" - }, - "dependencies": { - "xml2js": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", - "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", - "requires": { - "sax": ">=0.6.0", - "xmlbuilder": "~9.0.1" - } - } - } - }, - "plex-api-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/plex-api-headers/-/plex-api-headers-1.1.0.tgz", - "integrity": "sha1-TONkcV2WSMzPLAZFgyee+HwS+/I=" - }, "proxy-addr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", @@ -888,9 +912,9 @@ } }, "psl": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.7.0.tgz", - "integrity": "sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ==" + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, "punycode": { "version": "2.1.1", @@ -898,9 +922,19 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "qs": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", - "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "query-string": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.11.1.tgz", + "integrity": "sha512-1ZvJOUl8ifkkBxu2ByVM/8GijMIPx+cef7u3yroO3Ogm4DOdZcF5dcrWTIlSHe3Pg/mtlt6/eFjObDfJureZZA==", + "requires": { + "decode-uri-component": "^0.2.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + } }, "range-parser": { "version": "1.2.1", @@ -972,11 +1006,6 @@ "uuid": "^3.3.2" }, "dependencies": { - "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" - }, "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", @@ -984,25 +1013,6 @@ } } }, - "request-promise": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.4.tgz", - "integrity": "sha512-8wgMrvE546PzbR5WbYxUQogUnUDfM0S7QIFZMID+J73vdFARkFy+HElj4T+MWYhpXwlLp0EQ8Zoj8xUA0he4Vg==", - "requires": { - "bluebird": "^3.5.0", - "request-promise-core": "1.1.2", - "stealthy-require": "^1.1.1", - "tough-cookie": "^2.3.3" - } - }, - "request-promise-core": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz", - "integrity": "sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag==", - "requires": { - "lodash": "^4.17.11" - } - }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1022,20 +1032,15 @@ } }, "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" }, "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" - }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -1127,6 +1132,11 @@ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==" }, + "split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -1153,10 +1163,10 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" }, - "stealthy-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=" + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" }, "string-width": { "version": "1.0.2", @@ -1189,6 +1199,21 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, + "topo": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/topo/-/topo-3.0.3.tgz", + "integrity": "sha512-IgpPtvD4kjrJ7CRA3ov2FhWQADwv+Tdqbsf1ZnPUSAtCJ9e1Z44MmoSGDXGk4IppoZA7jd/QRkNddlLJWlUZsQ==", + "requires": { + "hoek": "6.x.x" + }, + "dependencies": { + "hoek": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-6.1.3.tgz", + "integrity": "sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ==" + } + } + }, "tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -1238,11 +1263,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, - "uuid": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.2.tgz", - "integrity": "sha1-SL1WmPBnfjx5AaHEbvFbFkN5RyY=" - }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -1313,30 +1333,16 @@ "resolved": "https://registry.npmjs.org/xml-writer/-/xml-writer-1.7.0.tgz", "integrity": "sha1-t28dWRwWomNOvbcDx729D9aBkGU=" }, - "xml2js": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.16.tgz", - "integrity": "sha1-+C/M0vlUDX4Km12sFj50cRlcnbM=", + "xml2json": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/xml2json/-/xml2json-0.12.0.tgz", + "integrity": "sha512-EPJHRWJnJUYbJlzR4pBhZODwWdi2IaYGtDdteJi0JpZ4OD31IplWALuit8r73dJuM4iHZdDVKY1tLqY2UICejg==", "requires": { - "sax": ">=0.6.0", - "xmlbuilder": "^4.1.0" - }, - "dependencies": { - "xmlbuilder": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-4.2.1.tgz", - "integrity": "sha1-qlijBBoGb5DqoWwvU4n/GfP0YaU=", - "requires": { - "lodash": "^4.0.0" - } - } + "hoek": "^4.2.1", + "joi": "^13.1.2", + "node-expat": "^2.3.18" } }, - "xmlbuilder": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" - }, "y18n": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", diff --git a/package.json b/package.json index dad826e..068f68f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "psuedotv-plex", + "name": "pseudotv-plex", "version": "1.0.0", - "description": "", + "description": "Create Live TV/DVR channels from playlists in Plex.", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", @@ -13,11 +13,11 @@ "dependencies": { "config-yml": "^0.10.3", "express": "^4.17.1", - "morgan": "^1.10.0", "node-ssdp": "^4.0.0", - "plex-api": "^5.3.1", + "query-string": "^6.11.1", "request": "^2.88.2", "xml-reader": "^2.4.3", - "xml-writer": "^1.7.0" + "xml-writer": "^1.7.0", + "xml2json": "^0.12.0" } } diff --git a/src/ffmpeg.js b/src/ffmpeg.js new file mode 100644 index 0000000..4223b30 --- /dev/null +++ b/src/ffmpeg.js @@ -0,0 +1,134 @@ +const Router = require('express').Router +const spawn = require('child_process').spawn +const config = require('config-yml') +const xmltv = require('./xmltv') + +module.exports = ffmpegRouter + +function ffmpegRouter(client) { + var router = Router() + var inUse = false + router.get('/video', (req, res) => { + if (inUse) + return res.status(409).send("Error: Another user is currently viewing a stream. One one active stream is allowed.") + inUse = true + var channel = req.query.channel + if (!channel) { + inUse = false + res.status(400).send("Error: No channel queried") + return + } + channel = channel.split('?')[0] + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-disposition': 'attachment; filename=video.ts' + }) + startStreaming(channel, res) + }) + + return router + + function startStreaming(channel, res) { + var programs = xmltv.readXMLPrograms() + // Find the current program for channel, calculate video start position + var startPos = -1 + var programIndex = -1 + var channelExists = false + for (var i = 0; i < programs.length; i++) { + var date = new Date() + if (programs[i].channel == channel) { + channelExists = true + if (programs[i].start <= date && programs[i].stop >= date) { + startPos = date.getTime() - programs[i].start.getTime() + programIndex = i + break + } + } + } + // End session if any errors. + if (!channelExists) { + inUse = false + res.status(403).send(`Error: Channel doesn't exist. Channel: ${channel}`) + return + } + if (programIndex === -1) { + inUse = false + res.status(403).send(`Error: No scheduled programming available. Channel: ${channel}`) + return + } + if (startPos === -1) { + inUse = false + res.status(403).send(`Error: How the fuck did you get here?. Channel: ${channel}`) + return + } + + // Query plex for current program + client.Get(programs[programIndex].key, (result) => { + if (result.err) { + inUse = false + res.status(403).send(`Error: Failed to fetch program info from Plex`) + return + } + var fetchedItem = result.result.MediaContainer.Metadata[0] + // Transcode it + client.Transcode(fetchedItem, startPos, (result) => { + if (result.err) { + inUse = false + res.status(403).send(`Error: Failed to add program to playQueue`) + return + } + // Update server timeline every 10 seconds + var stream = result.result + var msElapsed = startPos + var timelineInterval = setInterval(() => { + stream.update(msElapsed) + msElapsed += 10000 + }, 10000) + // Start transmuxing, pipe ffmpeg's output to stdout + var args = [ + '-re', // Live Stream + '-ss', startPos / 1000, // Start Time (eg: 00:01:23.000 or 83 (seconds)) + '-i', stream.url, // Source + '-f', 'mpegts', // Output Format + '-c', 'copy', // Copy Video/Audio Streams + 'pipe:1' // Output on stdout + ] + if (config.FFMPEG_OPTIONS.PREBUFFER) + args.shift() + var ffmpeg = spawn(config.FFMPEG_OPTIONS.PATH, args) + // Write the chunks to response + ffmpeg.stdout.on('data', (chunk) => { + res.write(chunk) + }) + // When the http session ends: kill ffmpeg + var httpEnd = function () { + ffmpeg.kill() + inUse = false + } + res.on('close', httpEnd) + // When ffmpeg closes: kill the timelineInterval, recurse to next program.. Since MPEGTS files can be concatenated together, this should work..... + ffmpeg.on('close', (code) => { + clearInterval(timelineInterval) + // if ffmpeg closed because we hit the end of the video.. + if (code === 0) { // stream the next episode + var end = programs[programIndex].stop + var now = new Date() + var timeUntilDone = end.valueOf() - now.valueOf() + timeUntilDone = timeUntilDone > 0 ? timeUntilDone : 0 + setTimeout(() => { + stream.stop() + res.removeListener('close', httpEnd) + startStreaming(channel, res) + }, timeUntilDone) // wait until end of video before we start sending the stream + } else if (inUse && !res.headersSent) { + stream.stop() + res.status(400).send(`Error: FFMPEG closed unexpectedly`) + inUse = false + } else { + stream.stop() + } + }) + }) + }) + } +} \ No newline at end of file diff --git a/src/hdhr.js b/src/hdhr.js deleted file mode 100644 index 049800c..0000000 --- a/src/hdhr.js +++ /dev/null @@ -1,400 +0,0 @@ -const Router = require('express').Router -const SSDP = require('node-ssdp').Server -const fs = require('fs') -const config = require('config-yml') - -const m3u = require('./m3u') - -var device = { - FriendlyName: "PseudoTV-Plex", - Manufacturer: "Silicondust", - ManufacturerURL: "https://github.com/DEFENDORe/pseudotv-plex", - ModelNumber: "HDTC-2US", - FirmwareName: "hdhomeruntc_atsc", - TunerCount: config.HDHR_OPTIONS.tuners, - FirmwareVersion: "20170930", - DeviceID: config.HDHR_OPTIONS.uuid, - DeviceAuth: "test1234", - BaseURL: `http://${config.HOST}:${config.PORT}`, - LineupURL: `http://${config.HOST}:${config.PORT}/lineup.json` -} - -const server = new SSDP({ - location: { - port: config.PORT, - path: '/device.xml' - }, - udn: `uuid:${device.DeviceID}`, - allowWildcards: true, - ssdpSig: 'PPTV/3.0 UPnP/1.0' -}) - -function startHDHR() { - server.addUSN('upnp:rootdevice') - server.addUSN('urn:schemas-upnp-org:device:MediaServer:1') - server.addUSN('urn:schemas-upnp-org:service:ContentDirectory:1') - server.addUSN('urn:schemas-upnp-org:service:ConnectionManager:1') - server.start() -} - -function HDHRRouter() { - - const router = Router() - router.get('/device.xml', (req, res) => { - res.header("Content-Type", "application/xml") - var data = ` - - 1 - 0 - - ${device.BaseURL} - - DMS-1.50 - VEN_0115&DEV_1040&SUBSYS_0001&REV_0004 VEN_0115&DEV_1040&SUBSYS_0001 VEN_0115&DEV_1040 - MediaDevices - Multimedia - urn:schemas-upnp-org:device:MediaServer:1 - ${device.FriendlyName} - / - ${device.Manufacturer} - ${device.ManufacturerURL} - ${device.FriendlyName} - ${device.FriendlyName} - ${device.ModelNumber} - ${device.ManufacturerURL} - - uuid:${device.DeviceID} - - - - urn:schemas-upnp-org:service:ConnectionManager:1 - urn:upnp-org:serviceId:ConnectionManager - /ConnectionManager.xml - ${device.BaseURL}/ConnectionManager.xml - ${device.BaseURL}/ConnectionManager.xml - - - urn:schemas-upnp-org:service:ContentDirectory:1 - urn:upnp-org:serviceId:ContentDirectory - /ContentDirectory.xml - ${device.BaseURL}/ContentDirectory.xml - ${device.BaseURL}/ContentDirectory.xml - - -` - res.send(data) - }) - - router.get('/ConnectionManager.xml', (req, res) => { - res.header("Content-Type", "application/xml") - var data = ` - - - - 1 - 0 - - - - GetProtocolInfo - - - Source - out - SourceProtocolInfo - - - Sink - out - SinkProtocolInfo - - - - - GetCurrentConnectionIDs - - - ConnectionIDs - out - CurrentConnectionIDs - - - - - GetCurrentConnectionInfo - - - ConnectionID - in - A_ARG_TYPE_ConnectionID - - - RcsID - out - A_ARG_TYPE_RcsID - - - AVTransportID - out - A_ARG_TYPE_AVTransportID - - - ProtocolInfo - out - A_ARG_TYPE_ProtocolInfo - - - PeerConnectionManager - out - A_ARG_TYPE_ConnectionManager - - - PeerConnectionID - out - A_ARG_TYPE_ConnectionID - - - Direction - out - A_ARG_TYPE_Direction - - - Status - out - A_ARG_TYPE_ConnectionStatus - - - - - - - SourceProtocolInfo - string - - - SinkProtocolInfo - string - - - CurrentConnectionIDs - string - - - A_ARG_TYPE_ConnectionStatus - string - - OK - ContentFormatMismatch - InsufficientBandwidth - UnreliableChannel - Unknown - - - - A_ARG_TYPE_ConnectionManager - string - - - A_ARG_TYPE_Direction - string - - Input - Output - - - - A_ARG_TYPE_ProtocolInfo - string - - - A_ARG_TYPE_ConnectionID - i4 - - - A_ARG_TYPE_AVTransportID - i4 - - - A_ARG_TYPE_RcsID - i4 - - - `; - res.send(data) - }) - - router.get('/ContentDirectory.xml', (req, res) => { - res.header("Content-Type", "application/xml") - var data = ` - - - - 1 - 0 - - - - Browse - - - ObjectID - in - A_ARG_TYPE_ObjectID - - - BrowseFlag - in - A_ARG_TYPE_BrowseFlag - - - Filter - in - A_ARG_TYPE_Filter - - - StartingIndex - in - A_ARG_TYPE_Index - - - RequestedCount - in - A_ARG_TYPE_Count - - - SortCriteria - in - A_ARG_TYPE_SortCriteria - - - Result - out - A_ARG_TYPE_Result - - - NumberReturned - out - A_ARG_TYPE_Count - - - TotalMatches - out - A_ARG_TYPE_Count - - - UpdateID - out - A_ARG_TYPE_UpdateID - - - - - GetSearchCapabilities - - - SearchCaps - out - SearchCapabilities - - - - - GetSortCapabilities - - - SortCaps - out - SortCapabilities - - - - - GetSystemUpdateID - - - Id - out - SystemUpdateID - - - - - - - A_ARG_TYPE_SortCriteria - string - - - A_ARG_TYPE_UpdateID - ui4 - - - A_ARG_TYPE_Filter - string - - - A_ARG_TYPE_Result - string - - - A_ARG_TYPE_Index - ui4 - - - A_ARG_TYPE_ObjectID - string - - - SortCapabilities - string - - - SearchCapabilities - string - - - A_ARG_TYPE_Count - ui4 - - - A_ARG_TYPE_BrowseFlag - string - - BrowseMetadata - BrowseDirectChildren - - - - SystemUpdateID - ui4 - - - ` - res.send(data) - }) - - router.get('/discover.json', (req, res) => { - res.header("Content-Type", "application/json") - res.send(JSON.stringify(device)) - }) - - router.get('/lineup_status.json', (req, res) => { - res.header("Content-Type", "application/json") - var data = { - ScanInProgress: 0, - ScanPossible: 1, - Source: "Cable", - SourceList: ["Cable"], - } - res.send(JSON.stringify(data)) - }) - - router.get('/lineup.json', (req, res) => { - res.header("Content-Type", "application/json") - var data = m3u.ReadChannels() - res.send(JSON.stringify(data)) - }) - return router -} - -module.exports = { router: HDHRRouter, start: startHDHR } \ No newline at end of file diff --git a/src/hdhr/device.js b/src/hdhr/device.js new file mode 100644 index 0000000..294c487 --- /dev/null +++ b/src/hdhr/device.js @@ -0,0 +1,62 @@ +var config = require('config-yml') + +function device() { + var device = { + friendlyName: "PseudoTV", + manufacturer: "Silicondust", + manufacturerURL: "https://github.com/DEFENDORe/pseudotv-plex", + modelNumber: "HDTC-2US", + firmwareName: "hdhomeruntc_atsc", + tunerCount: 1, + firmwareVersion: "20170930", + deviceID: 'PseudoTV', + deviceAuth: "", + baseURL: `http://${config.HOST}:${config.PORT}`, + lineupURL: `http://${config.HOST}:${config.PORT}/lineup.json` + } + device.getXml = () => { + return ` + + 1 + 0 + + ${device.baseURL} + + DMS-1.50 + VEN_0115&DEV_1040&SUBSYS_0001&REV_0004 VEN_0115&DEV_1040&SUBSYS_0001 VEN_0115&DEV_1040 + MediaDevices + Multimedia + urn:schemas-upnp-org:device:MediaServer:1 + ${device.friendlyName} + / + ${device.manufacturer} + ${device.manufacturerURL} + ${device.friendlyName} + ${device.friendlyName} + ${device.modelNumber} + ${device.manufacturerURL} + + uuid:${device.deviceID} + + + + urn:schemas-upnp-org:service:ConnectionManager:1 + urn:upnp-org:serviceId:ConnectionManager + /ConnectionManager.xml + ${device.baseURL}/ConnectionManager.xml + ${device.baseURL}/ConnectionManager.xml + + + urn:schemas-upnp-org:service:ContentDirectory:1 + urn:upnp-org:serviceId:ContentDirectory + /ContentDirectory.xml + ${device.baseURL}/ContentDirectory.xml + ${device.baseURL}/ContentDirectory.xml + + + ` + } + return device +} + +module.exports = device \ No newline at end of file diff --git a/src/hdhr/index.js b/src/hdhr/index.js new file mode 100644 index 0000000..cc85dcb --- /dev/null +++ b/src/hdhr/index.js @@ -0,0 +1,57 @@ +const express = require('express') +const SSDP = require('node-ssdp').Server +const config = require('config-yml') +const m3u = require('../m3u') + +function hdhr() { + var device = require('./device')() + + const server = new SSDP({ + location: { + port: config.PORT, + path: '/device.xml' + }, + udn: `uuid:${device.deviceID}`, + allowWildcards: true, + ssdpSig: 'PsuedoTV/0.1 UPnP/1.0' + }) + server.addUSN('upnp:rootdevice') + server.addUSN('urn:schemas-upnp-org:device:MediaServer:1') + server.addUSN('urn:schemas-upnp-org:service:ContentDirectory:1') + server.addUSN('urn:schemas-upnp-org:service:ConnectionManager:1') + + var router = express.Router() + + router.get('/device.xml', (req, res) => { + res.header("Content-Type", "application/xml") + var data = device.getXml() + res.send(data) + }) + + router.use(express.static('./static')) + + router.get('/discover.json', (req, res) => { + res.header("Content-Type", "application/json") + res.send(JSON.stringify(device)) + }) + + router.get('/lineup_status.json', (req, res) => { + res.header("Content-Type", "application/json") + var data = { + ScanInProgress: 0, + ScanPossible: 1, + Source: "Cable", + SourceList: ["Cable"], + } + res.send(JSON.stringify(data)) + }) + router.get('/lineup.json', (req, res) => { + res.header("Content-Type", "application/json") + var data = m3u.ReadChannels() + res.send(JSON.stringify(data)) + }) + + return { router: router, ssdp: server } +} + +module.exports = hdhr \ No newline at end of file diff --git a/src/m3u.js b/src/m3u.js index a79844f..d0dfc29 100644 --- a/src/m3u.js +++ b/src/m3u.js @@ -7,13 +7,13 @@ function WriteM3U(channels, cb) { data += `#EXTINF:0 tvg-id="${channels[i].channel}" tvg-name="${channels[i].name}" tvg-logo="${channels[i].icon}",${channels[i].channel}\n` data += `http://${config.HOST}:${config.PORT}/video?channel=${channels[i].channel}\n` } - fs.writeFileSync(config.M3U_OUTPUT, data) + fs.writeFileSync(config.M3U_FILE, data) if (typeof cb == 'function') cb() } // Formatted for HDHR lineup.. function ReadChannels() { - var m3uData = fs.readFileSync(config.M3U_OUTPUT) + var m3uData = fs.readFileSync(config.M3U_FILE) var track = m3uData.toString().split(/[\n]+/) var channels = [] track.splice(0, 1) diff --git a/src/plex.js b/src/plex.js deleted file mode 100644 index 7b924d2..0000000 --- a/src/plex.js +++ /dev/null @@ -1,83 +0,0 @@ -const plex = require('plex-api') -const config = require('config-yml') - -const client = new plex(config.PLEX_OPTIONS) - -function PlexChannelScan(cb) { - getPsuedoTVPlaylists((lineup) => { - getAllPlaylistsInfo(lineup, cb) - }) -} - -function getPsuedoTVPlaylists(cb) { - var lineup = [] - client.query("/playlists/").then((result) => { - var playlists = result.MediaContainer - for (var i = 0; playlists.size > 0 && i < playlists.Metadata.length; i++) { - var summaryData = playlists.Metadata[i].summary.split(/\s+/) - if (playlists.Metadata[i].playlistType == 'video' && summaryData.length > 0 && summaryData[0].toLowerCase() == config.PLEX_PLAYLIST_SUMMARY_KEY) { - var channelNumber = playlists.Metadata[i].ratingKey - var channelIcon = "" - if (summaryData.length > 1) { - if (!isNaN(summaryData[1])) - channelNumber = summaryData[1] - else if (validURL(summaryData[1])) - channelIcon = summaryData[1] - } - if (summaryData.length > 2) { - if (!isNaN(summaryData[2])) - channelNumber = summaryData[2] - else if (validURL(summaryData[2])) - channelIcon = summaryData[2] - } - lineup.push({ id: playlists.Metadata[i].ratingKey, channel: channelNumber, name: playlists.Metadata[i].title, icon: channelIcon, summary: playlists.Metadata[i].summary, updatedAt: playlists.Metadata[i].updatedAt }) - } - } - cb(lineup) - }, (err) => { - console.error("Could not connect to Plex server", err) - }) -} - -function getAllPlaylistsInfo(lineup, cb) { - var channelIndex = 0 - if (lineup.length == 0) - return cb([]) - var lastUpdatedAt = 0 - var channelInfo = [] - getPlaylist(channelIndex, () => { - cb(lineup, lastUpdatedAt, channelInfo.join()) - }) - // Fetch each playlist (channel) recursivley from Plex - function getPlaylist(i, _cb) { - client.query("/playlists/" + lineup[i].id + "/items").then(function (result) { - var playlist = result.MediaContainer.Metadata - lastUpdatedAt = lastUpdatedAt > lineup[i].updatedAt ? lastUpdatedAt : lineup[i].updatedAt - channelInfo.push(lineup[i].name) - channelInfo.push(lineup[i].summary) - lineup[i].duration = typeof result.MediaContainer.duration !== 'undefined' ? result.MediaContainer.duration : 0 - lineup[i].playlist = typeof playlist !== 'undefined' ? playlist : [] - channelIndex++ - if (channelIndex < lineup.length) - getPlaylist(channelIndex, _cb) - else - _cb() - }, function (err) { - console.error("Could not connect to Plex server", err) - }) - } -} - -function validURL(str) { - var pattern = new RegExp('^(https?:\\/\\/)?' + // protocol - '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name - '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address - '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path - '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string - '(\\#[-a-z\\d_]*)?$', 'i'); // fragment locator - return !!pattern.test(str); -} - -module.exports = { - PlexChannelScan: PlexChannelScan -} \ No newline at end of file diff --git a/src/plex/channels.js b/src/plex/channels.js new file mode 100644 index 0000000..383830a --- /dev/null +++ b/src/plex/channels.js @@ -0,0 +1,96 @@ +function getPsuedoTVPlaylists(_client, cb) { + var lineup = [] + _client.Get("/playlists/", (result) => { + var playlists = result.result.MediaContainer + for (var i = 0; playlists.size > 0 && i < playlists.Metadata.length; i++) { + var summaryData = playlists.Metadata[i].summary.split(/\s+/) + if (playlists.Metadata[i].playlistType == 'video' && summaryData.length > 0 && summaryData[0].toLowerCase() == 'pseudotv') { + var channelNumber = playlists.Metadata[i].ratingKey + var channelIcon = "" + var shuffle = false + if (summaryData.length > 1) { + if (!isNaN(summaryData[1])) + channelNumber = summaryData[1] + else if (validURL(summaryData[1])) + channelIcon = summaryData[1] + else if (summaryData[1] === 'shuffle') + shuffle = true + } + if (summaryData.length > 2) { + if (!isNaN(summaryData[2])) + channelNumber = summaryData[2] + else if (validURL(summaryData[2])) + channelIcon = summaryData[2] + else if (summaryData[2] === 'shuffle') + shuffle = true + } + if (summaryData.length > 3) { + if (!isNaN(summaryData[3])) + channelNumber = summaryData[3] + else if (validURL(summaryData[3])) + channelIcon = summaryData[3] + else if (summaryData[3] === 'shuffle') + shuffle = true + } + lineup.push({ + id: playlists.Metadata[i].ratingKey, + channel: channelNumber, + shuffle: shuffle, + name: playlists.Metadata[i].title, + icon: channelIcon, + summary: playlists.Metadata[i].summary + }) + } + } + cb(lineup) + }) +} + +function getAllPlaylistsInfo(_client, lineup, cb) { + var channelIndex = 0 + if (lineup.length == 0) + return cb([]) + getPlaylist(channelIndex, () => { cb(lineup) }) + // Fetch each playlist (channel) recursivley from Plex + function getPlaylist(i, _cb) { + _client.Get("/playlists/" + lineup[i].id + "/items", (result) => { + var playlist = result.result.MediaContainer.Metadata + lineup[i].items = typeof playlist !== 'undefined' ? playlist : [] + if (lineup[i].shuffle) + shuffle(lineup[i].items) + channelIndex++ + if (channelIndex < lineup.length) + getPlaylist(channelIndex, _cb) + else + _cb() + }) + } +} + +module.exports = { + getPsuedoTVPlaylists: getPsuedoTVPlaylists, + getAllPlaylistsInfo: getAllPlaylistsInfo +} + +function validURL(str) { + var pattern = new RegExp('^(https?:\\/\\/)?' + // protocol + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name + '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address + '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path + '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string + '(\\#[-a-z\\d_]*)?$', 'i'); // fragment locator + return !!pattern.test(str); +} + +var shuffle = function (items) { + var i = items.length + var tmp, r + while (i !== 0) { + r = Math.floor(Math.random() * i) + i-- + tmp = items[i] + items[i] = items[r] + items[r] = tmp + } + return items +} \ No newline at end of file diff --git a/src/plex/index.js b/src/plex/index.js new file mode 100644 index 0000000..de2d5f0 --- /dev/null +++ b/src/plex/index.js @@ -0,0 +1,159 @@ +const request = require('request') +const xmlParser = require('xml2json') +const config = require('config-yml') +const path = require('path') +const requests = require('./requests') +const playlists = require('./channels') + +const plex = function (opts, callback) { + + if (typeof opts.username === 'undefined' || typeof opts.password === 'undefined') + return callback({ err: "Error - No Username or Password was provided" }) + if (typeof opts.hostname === 'undefined' || typeof opts.port === 'undefined') + return callback({ err: "Error - No Hostname or Port was provided" }) + + const OPTIONS = { + HOSTNAME: opts.hostname, + PORT: opts.port, + CLIENT_ID: 'rg14zekk3pa5zp4safjwaa8z', + PRODUCT: 'PseudoTV', + VERSION: '0.1', + DEVICE: 'PseudoTV - JS', + PLATFORM: 'Chrome', + PLATFORM_VERSION: '80.0' + } + + // Login via plex.tv, return client via callback + request(requests.login(opts.username, opts.password, OPTIONS), (err, res, body) => { + if (err || res.statusCode !== 201) + return callback({ err: "Unauthorized - Username/Email and Password is incorrect!." }) + var _client = client() + _client.authToken = JSON.parse(body).user.authToken + _client.Get('/', (result) => { + if (result.err) + return callback({ err: "Failed to connect to server." }) + _client._poll() + _client.serverId = result.result.MediaContainer.machineIdentifier + callback({ client: _client }) + }) + }) + const client = function () { + var _this = this + _this.OPTIONS = OPTIONS + // Private + _this._killed = false + _this.serverId = '' + _this.authToken = '' + _this._poll = function () { + request(requests.poll(_this), (err, res) => { + if (!_this._killed && !err && res.statusCode === 200) + _this._poll() // recurse, plex returns response every 20 seconds. + }) + } + + _this._updateTimeline = (item, playQueueItemID, state, time, cb) => { + var callback = typeof cb === 'function' ? cb : () => { } + + request(requests.timeline(_this, item, playQueueItemID, state, time), (err, res) => { + if (err || res.statusCode !== 200) + callback({ err: "Get Request Failed" }) + else + callback({ result: JSON.parse(xmlParser.toJson(res.body, { arrayNotation: true })) }) + }) + } + _this._createPlayQueue = function (key, callback) { + + request(requests.queue(_this, key), (err, res) => { + if (err && res.statusCode !== 200) + callback({ err: "Post Request Failed" }) + else + callback({ result: JSON.parse(res.body) }) + }) + } + _this._getTranscodeURL = (item) => { + return requests.transcode(_this, item) + } + + _this._refreshGuide = (dvrID) => { + request(requests.refreshGuide(_this, dvrID)) + } + + _this._refreshChannels = (dvrID, channels) => { + request(requests.refreshChannels(_this, dvrID, channels)) + } + + // Public + _this.Close = () => { _this._killed = true } + + _this.Get = function (path, callback) { + request(requests.get(_this, path), (err, res) => { + if (err || res.statusCode !== 200) + callback({ err: "Get Request Failed" }) + else + callback({ result: JSON.parse(res.body) }) + }) + } + + _this.Transcode = function (item, msElapsed, cb) { + _createPlayQueue(item.key, (res) => { + if (res.err) + cb(res) + var playQueueID = res.result.MediaContainer.playQueueID + _updateTimeline(item, playQueueID, 'playing', msElapsed) + var stop = () => { _updateTimeline(item, playQueueID, 'stopped', 0) } + var update = (_msElapsed) => { _updateTimeline(item, playQueueID, 'playing', _msElapsed) } + cb({ result: { url: _getTranscodeURL(item), stop: stop, update: update }}) + }) + } + + _this.PseudoTVChannelScan = function (cb) { + playlists.getPsuedoTVPlaylists(_this, (lineup) => { + playlists.getAllPlaylistsInfo(_this, lineup, cb) + }) + } + + _this.RefreshGuide = function () { + GetPseudoTVDVRS((result) => { + var dvrs = result.result + dvrs = typeof dvrs === 'undefined' ? [] : dvrs + for (var i = 0; i < dvrs.length; i++) { + var xmlfile = dvrs[i].lineup.split('lineup://tv.plex.providers.epg.xmltv/') + xmlfile = xmlfile[xmlfile.length - 1].split('#')[0] + if (path.resolve(xmlfile) === path.resolve(config.XMLTV_FILE)) { + _refreshGuide(dvrs[i].key) + } + } + }) + } + _this.RefreshChannels = function (channels) { + GetPseudoTVDVRS((result) => { + var dvrs = result.result + dvrs = typeof dvrs === 'undefined' ? [] : dvrs + for (var i = 0; i < dvrs.length; i++) { + for (var y = 0; y < dvrs[i].Device.length; y++) { + _refreshChannels(dvrs[i].Device[y].key, channels) + } + } + }) + } + _this.GetPseudoTVDVRS = function (cb) { + Get('/livetv/dvrs', (result) => { + if (result.err) + return cb(result) + var dvrs = result.result.MediaContainer.Dvr + dvrs = typeof dvrs === 'undefined' ? [] : dvrs + var _dvrs = [] + for (var i = 0; i < dvrs.length; i++) { + var xmlfile = dvrs[i].lineup.split('lineup://tv.plex.providers.epg.xmltv/') + xmlfile = xmlfile[xmlfile.length - 1].split('#')[0] + if (path.resolve(xmlfile) === path.resolve(config.XMLTV_FILE)) + _dvrs.push(dvrs[i]) + } + cb({result: _dvrs}) + }) + } + return _this + } +} + +module.exports = plex \ No newline at end of file diff --git a/src/plex/requests.js b/src/plex/requests.js new file mode 100644 index 0000000..7b5c2e3 --- /dev/null +++ b/src/plex/requests.js @@ -0,0 +1,249 @@ +const queryString = require('query-string') +const config = require('config-yml') + +module.exports = { + login: login, + poll: poll, + get: get, + timeline: timeline, + queue: queue, + transcode: transcode, + refreshGuide: refreshGuide, + refreshChannels: refreshChannels +} + +function login(username, password, OPTIONS) { + return { + method: 'post', + url: 'https://plex.tv/users/sign_in.json', + headers: { + 'X-Plex-Platform': OPTIONS.PLATFORM, + 'X-Plex-Platform-Version': OPTIONS.PLATFORM_VERSION, + 'X-Plex-Provides': 'timeline,playback,navigation,mirror,playqueues', + 'X-Plex-Client-Identifier': OPTIONS.CLIENT_ID, + 'X-Plex-Product': OPTIONS.PRODUCT, + 'X-Plex-Version': OPTIONS.VERSION, + 'X-Plex-Device': OPTIONS.DEVICE, + 'X-Plex-Device-Name': OPTIONS.DEVICE + }, + form: { + user: { + login: username, + password: password + } + } + } +} + +function poll(_client) { + return { + method: 'get', + url: `http://${OPTIONS.HOSTNAME}:${OPTIONS.PORT}/player/proxy/poll`, + qs: { + deviceClass: 'pc', + protocolVersion: 3, + protocolCapabilities: 'timeline,playback,navigation,mirror,playqueues', + timeout: 1, + 'X-Plex-Provides': 'timeline,playback,navigation,mirror,playqueues', + 'X-Plex-Product': _client.OPTIONS.PRODUCT, + 'X-Plex-Version': _client.OPTIONS.VERSION, + 'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID, + 'X-Plex-Platform': _client.OPTIONS.PLATFORM, + 'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION, + 'X-Plex-Sync-Version': 2, + 'X-Plex-Features': 'external-media,internal-media', + 'X-Plex-Device': _client.OPTIONS.DEVICE, + 'X-Plex-Device-Name': _client.OPTIONS.DEVICE, + 'X-Plex-Token': _client.authToken + } + } +} + +function get(_client, path) { + return { + method: 'get', + url: `http://${OPTIONS.HOSTNAME}:${OPTIONS.PORT}${path}`, + headers: { + 'Accept': 'application/json', + 'X-Plex-Token': _client.authToken, + 'X-Plex-Device': _client.OPTIONS.DEVICE, + 'X-Plex-Device-Name': _client.OPTIONS.DEVICE, + 'X-Plex-Product': _client.OPTIONS.PRODUCT, + 'X-Plex-Version': _client.OPTIONS.VERSION, + 'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID, + 'X-Plex-Platform': _client.OPTIONS.PLATFORM, + 'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION, + } + } +} + +function timeline(_client, item, pQid, state, time) { + return { + method: 'get', + url: `http://${_client.OPTIONS.HOSTNAME}:${_client.OPTIONS.PORT}/:/timeline`, + qs: { + ratingKey: item.ratingKey, + key: item.key, + playbackTime: 0, + playQueueItemID: pQid, + state: state, + hasMDE: 1, + time: time, + duration: item.duration, + 'X-Plex-Session-Identifier': config.PLEX_SESSION_ID, + 'X-Plex-Token': _client.authToken, + 'X-Plex-Device': _client.OPTIONS.DEVICE, + 'X-Plex-Device-Name': _client.OPTIONS.DEVICE, + 'X-Plex-Product': _client.OPTIONS.PRODUCT, + 'X-Plex-Version': _client.OPTIONS.VERSION, + 'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID, + 'X-Plex-Platform': _client.OPTIONS.PLATFORM, + 'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION, + 'X-Plex-Sync-Version': 2, + 'X-Plex-Features': 'external-media,indirect-media', + 'X-Plex-Model': 'bundled', + 'X-Plex-Device-Screen-Resolution': '1920x1080', + 'X-Plex-Language': 'en', + 'X-Plex-Text-Format': 'plain', + 'X-Plex-Provider-Version': '1.3', + 'X-Plex-Drm': 'widevine' + } + } +} + +function queue(_client, key) { + return { + method: 'post', + url: `http://${_client.OPTIONS.HOSTNAME}:${_client.OPTIONS.PORT}/playQueues`, + headers: { + 'Accept': 'application/json', + }, + qs: { + type: 'video', + extrasPrefixCount: 0, + uri: `server://${_client.serverId}/com.plexapp.plugins.library${key}`, + repeat: 0, + own: 1, + includeChapters: 1, + includeGeolocation: 1, + includeExternalMedia: 1, + 'X-Plex-Token': _client.authToken, + 'X-Plex-Device': _client.OPTIONS.DEVICE, + 'X-Plex-Device-Name': _client.OPTIONS.DEVICE, + 'X-Plex-Product': _client.OPTIONS.PRODUCT, + 'X-Plex-Version': _client.OPTIONS.VERSION, + 'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID, + 'X-Plex-Platform': _client.OPTIONS.PLATFORM, + 'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION, + 'X-Plex-Sync-Version': 2, + 'X-Plex-Features': 'external-media,indirect-media', + 'X-Plex-Model': 'bundled', + 'X-Plex-Device-Screen-Resolution': '1920x1080', + 'X-Plex-Language': 'en', + 'X-Plex-Text-Format': 'plain', + 'X-Plex-Provider-Version': '1.3', + 'X-Plex-Drm': 'widevine' + } + } +} + +function transcode(_client, item) { + return queryString.stringifyUrl({ + url: `http://${_client.OPTIONS.HOSTNAME}:${_client.OPTIONS.PORT}/video/:/transcode/universal/start.mpd`, + query: { + hasMDE: 1, + path: item.key, + mediaIndex: 0, + partIndex: 0, + protocol: 'dash', + fastSeek: 1, + directPlay: 0, + directStream: 0, + subtitleSize: 100, + audioBoost: 100, + location: 'lan', + addDebugOverlay: 0, + autoAdjustQuality: 0, + directStreamAudio: 1, + mediaBufferSize: 102400, + session: 'wtfisthisusedfor', + subtitles: 'burn', + 'Accept-Language': 'en', + 'X-Plex-Session-Identifier': config.PLEX_SESSION_ID, + 'X-Plex-Client-Profile-Extra': 'append-transcode-target-codec(type=videoProfile&context=streaming&audioCodec=aac&protocol=dash)', + //'X-Plex-Client-Profile-Extra': 'add-limitation(scope=videoCodec&scopeName=*&type=upperBound&name=video.bitrate&value=4000&replace=true)+append-transcode-target-codec(type=videoProfile&context=streaming&audioCodec=aac&protocol=dash)', + 'X-Plex-Product': _client.OPTIONS.PRODUCT, + 'X-Plex-Version': _client.OPTIONS.VERSION, + 'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID, + 'X-Plex-Platform': _client.OPTIONS.PLATFORM, + 'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION, + 'X-Plex-Sync-Version': 2, + 'X-Plex-Features': 'external-media,indirect-media', + 'X-Plex-Device': _client.OPTIONS.DEVICE, + 'X-Plex-Device-Name': _client.OPTIONS.DEVICE, + 'X-Plex-Model': 'bundled', + 'X-Plex-Device-Screen-Resolution': '1920x1080,1920x1080', + 'X-Plex-Token': _client.authToken, + 'X-Plex-Language': 'en' + } + }) +} + +function refreshGuide(_client, dvrID) { + return { + method: 'post', + url: `http://${_client.OPTIONS.HOSTNAME}:${_client.OPTIONS.PORT}/livetv/dvrs/${dvrID}/reloadGuide`, + headers: { + 'Accept': 'application/json', + }, + qs: { + 'X-Plex-Product': _client.OPTIONS.PRODUCT, + 'X-Plex-Version': _client.OPTIONS.VERSION, + 'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID, + 'X-Plex-Platform': _client.OPTIONS.PLATFORM, + 'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION, + 'X-Plex-Sync-Version': 2, + 'X-Plex-Features': 'external-media,indirect-media', + 'X-Plex-Model': 'bundled', + 'X-Plex-Device': _client.OPTIONS.DEVICE, + 'X-Plex-Device-Name': _client.OPTIONS.DEVICE, + 'X-Plex-Device-Screen-Resolution': '1920x1080,1920x1080', + 'X-Plex-Token': _client.authToken, + 'X-Plex-Language': 'en' + } + } +} + +function refreshChannels(_client, dvrID, channels) { + var qs = { + 'X-Plex-Product': _client.OPTIONS.PRODUCT, + 'X-Plex-Version': _client.OPTIONS.VERSION, + 'X-Plex-Client-Identifier': _client.OPTIONS.CLIENT_ID, + 'X-Plex-Platform': _client.OPTIONS.PLATFORM, + 'X-Plex-Platform-Version': _client.OPTIONS.PLATFORM_VERSION, + 'X-Plex-Sync-Version': 2, + 'X-Plex-Features': 'external-media,indirect-media', + 'X-Plex-Model': 'bundled', + 'X-Plex-Device': _client.OPTIONS.DEVICE, + 'X-Plex-Device-Name': _client.OPTIONS.DEVICE, + 'X-Plex-Device-Screen-Resolution': '1920x1080,1920x1080', + 'X-Plex-Token': _client.authToken, + 'X-Plex-Language': 'en' + } + var _channels = [] + for (var i = 0; i < channels.length; i ++) + _channels.push(channels[i].channel) + qs.channelsEnabled = _channels.join(',') + for (var i = 0; i < _channels.length; i ++) { + qs[`channelMapping[${_channels[i]}]`] = _channels[i] + qs[`channelMappingByKey[${_channels[i]}]`] = _channels[i] + } + return { + method: 'put', + url: `http://${_client.OPTIONS.HOSTNAME}:${_client.OPTIONS.PORT}/media/grabbers/devices/${dvrID}/channelmap`, + headers: { + 'Accept': 'application/json', + }, + qs: qs + } +} \ No newline at end of file diff --git a/src/pseudotv.js b/src/pseudotv.js new file mode 100644 index 0000000..4440f08 --- /dev/null +++ b/src/pseudotv.js @@ -0,0 +1,251 @@ +const Router = require('express').Router +const config = require('config-yml') +const path = require('path') + +module.exports = pseudotv + +function pseudotv(client, xmltv, m3u) { + var counter = config.EPG_UPDATE * 60 * 1000 + restartEPG(client, xmltv, m3u, config.PLEX_AUTO_REFRESH_GUIDE, config.PLEX_AUTO_REMAP_CHANNELS, () => { + console.log("Initial EPG Generated.") + }) + if (config.EPG_UPDATE !== 0) { + setInterval(() => { + counter -= 1000 + if (counter === 0) + updateEPG(client, xmltv, m3u, config.PLEX_AUTO_REFRESH_GUIDE, config.PLEX_AUTO_REMAP_CHANNELS, () => { + console.log("Updated EPG via Scheduled Refresh.") + counter = config.EPG_UPDATE * 60 * 1000 + }) + }, 1000) + } + + var router = Router() + + router.get('/', (req, res) => { + if (req.query.refresh === 'true') + updateEPG(client, xmltv, m3u, + req.query.rg === 'true' ? true : false, + req.query.rc === 'true' ? true : false, () => { + counter = config.EPG_UPDATE * 60 * 1000 + return res.status(200).send() + }) + else if (req.query.restart === 'true') + restartEPG(client, xmltv, m3u, + req.query.rg === 'true' ? true : false, + req.query.rc === 'true' ? true : false, () => { + counter = config.EPG_UPDATE * 60 * 1000 + return res.status(200).send() + }) + else + client.GetPseudoTVDVRS((result) => { + res.status(200).send(createHTML(result.result, xmltv.readXMLChannels(), counter / 1000)) + }) + }) + return router +} + +function updateEPG(client, xmltv, m3u, rg, rc, cb) { + client.PseudoTVChannelScan((channels) => { + xmltv.UpdateXMLTV(channels, () => { + m3u.WriteM3U(channels, () => { + if (rg) + client.RefreshGuide() + if (rc) + client.RefreshChannels(channels) + cb() + }) + }) + }) +} + +function restartEPG(client, xmltv, m3u, rg, rc, cb) { + client.PseudoTVChannelScan((channels) => { + xmltv.WriteXMLTV(channels, () => { + m3u.WriteM3U(channels, () => { + if (rg) + client.RefreshGuide() + if (rc) + client.RefreshChannels(channels) + cb() + }) + }) + }) +} + +function createHTML(dvrs, channels, counter) { + var str = ` + + + + pseudotv-plex + + + + +
+

pseudotv-plex

+

Create live TV channels from your Plex playlists.

+
+
+

Pseudo Channels

+
+ Total channels in XMLTV file: ${channels.length} + ${createChannelTable(channels)} +
+
+

EPG Utility

+

Scan Plex for ${config.PLEX_PLAYLIST_IDENTIFIER} playlists.

+
+ ${createEPGUtility(dvrs)} +
+
+

Configuration

+

Any changes made to config.yml won't take effect until pseudotv-plex is restarted.

+
+ ${createConfigDetails()} +
+

Author: Dan Ferguson

+ ${createScript(counter)} + + ` + return str.split(' ').join('') +} + +function createConfigDetails() { + var str = `

+ Host: ${config.HOST}
+ Port: ${config.PORT} +


+ Plex Server Host: ${config.PLEX_OPTIONS.hostname}
+ Plex Server Port: ${config.PLEX_OPTIONS.port}
+ XMLTV: ${path.resolve(config.XMLTV_FILE)}
+ M3U: ${path.resolve(config.M3U_FILE)} +
+ ${config.HDHOMERUN_OPTIONS.ENABLED ? `HDHomeRun Tuner: ${config.HOST}:${config.PORT}
` : ''} + HDHomeRun Tuner: ${config.HDHOMERUN_OPTIONS.ENABLED ? 'Enabled' : 'Disabled'} + ${config.HDHOMERUN_OPTIONS.ENABLED ? `
HDHomeRun Auto-Discovery: ${config.HDHOMERUN_OPTIONS.AUTODISCOVERY ? 'Enabled' : 'Disabled'}` : ''} +
+ MPEGTS Streaming Muxer: ${config.MUXER.toUpperCase()}
+ ${config.MUXER.toUpperCase()} Location: ${config.MUXER.toLowerCase() === 'ffmpeg' ? path.resolve(config.FFMPEG_OPTIONS.PATH) : path.resolve(config.VLC_OPTIONS.PATH)} + ${config.MUXER.toLowerCase() === 'ffmpeg' ? `
FFMPEG Prebuffering: ${config.FFMPEG_OPTIONS.PREBUFFER ? 'Enabled' : 'Disabled'}` : ''} + ${config.MUXER.toLowerCase() === 'vlc' ? `
VLC HTTP Server Port: ${config.VLC_OPTIONS.PORT}` : ''} + ${config.MUXER.toLowerCase() === 'vlc' ? `
VLC Streaming Delay (ms): ${config.VLC_OPTIONS.DELAY}` : ''} + ${config.MUXER.toLowerCase() === 'vlc' ? `
VLC Session Visibility: ${config.VLC_OPTIONS.HIDDEN ? 'Hidden' : 'Visible'}` : ''} +
+ EPG Cache: ${config.EPG_CACHE} Hours
+ EPG Update: ${config.EPG_UPDATE === 0 ? 'Never' : config.EPG_UPDATE + ' Minutes'}
+ Auto Refresh Plex Guide: ${config.PLEX_AUTO_REFRESH_GUIDE ? 'Yes' : 'No'}
+ Auto Refresh Plex DVR Channels: ${config.PLEX_AUTO_REMAP_CHANNELS ? 'Yes' : 'No'} +
+ Plex Playlist Summary Identifier: ${config.PLEX_PLAYLIST_IDENTIFIER}
+ X-Plex-Session-Identifier: ${config.PLEX_SESSION_ID} +

` + return str.split(' ').join('') +} + +var createChannelTable = (channels) => { + var str = ` + + + + + + ` + for (var i = 0; i < channels.length; i++) { + str += ` + + + + + ` + } + str += `
#NameIconShuffle
${channels[i].channel}${channels[i].name}${channels[i].icon ? `` : ''}${channels[i].shuffle}
` + if (channels.length === 0) { + str += `
Initial Setup
+
    +
  1. Create a video playlist. Tip: you can utilize Plex Smart Playlist's to create dynamic channels.
  2. +
  3. Edit the playlist's summary/description, write ${config.PLEX_PLAYLIST_IDENTIFIER} at the beginning to identify the playlist as a channel.
  4. +
  5. Restart pseudotv-plex, or use the 'EPG Utilty' below to update/restart your EPG.
  6. +
  7. 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.
  8. +
  9. Enjoy your pseudo live TV
  10. ` + } + return str.split(' ').join('') +} +function createEPGUtility(dvrs) { + var str = `
    +

    Plex Server LiveTV/DVR Auto Refresh Options${dvrs.length > 0 ? '' : '
    (Could not find a PseudoTV DVR in Plex)'}

    +
    + + +
    +
    +
    +
    + +

    Updates the XMLTV file in such a way that channel timelines are uninterupted, if possible.

    +
    +
    + +

    Rewrites the XMLTV file, every channels timeline will begin now.

    +
    +
    + +
    +

    Restart Plex client apps and refresh Plex web sessions to view changes..

    + ${config.EPG_UPDATE === 0 ? '' : `

    Next EPG Refresh: (hh:mm:ss)

    `} + ` + return str.split(' ').join('') +} + +var createScript = function (counter) { + var str = ` + ` + return str +} \ No newline at end of file diff --git a/src/vlc.js b/src/vlc.js index 39705ae..3b9f731 100644 --- a/src/vlc.js +++ b/src/vlc.js @@ -5,77 +5,143 @@ const config = require('config-yml') const xmltv = require('./xmltv') -module.exports = { router: vlcRouter } +module.exports = vlcRouter -function vlcRouter() { +function vlcRouter(client) { var router = Router() - var streams = [] - + var inUse = false router.get('/video', (req, res) => { - var programs = xmltv.readXMLPrograms() - if (!req.query.channel) - return res.status(422).send("No channel queried") - - req.query.channel = req.query.channel.split('?')[0] - var streamIndex = -1 - for (var i = 0; i < streams.length; i++) { - if (streams[i].channel === req.query.channel) { - streamIndex = i - break - } + if (inUse) + return res.status(409).send("Error: Another user is currently viewing a stream. One one active stream is allowed.") + inUse = true + var channel = req.query.channel + if (!channel) { + inUse = false + res.status(400).send("Error: No channel queried") + return } + channel = channel.split('?')[0] + + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-disposition': 'attachment; filename=video.ts' + }) + startStreaming(channel, res) + }) - if (streamIndex != -1) { - streams[streamIndex].viewers++ - request('http://' + config.HOST + ':' + streams[streamIndex].port + '/').on('error', (err) => {/* ignore errors */}).pipe(res) - } else { - var args = [] - var startPos = 0 - var programIndex = 0 - for (var i = 0; i < programs.length; i++) { - var date = new Date() - if (programs[i].start <= date && programs[i].stop >= date && programs[i].channel == req.query.channel) { - var dif = date.getTime() - programs[i].start.getTime() - startPos = dif / 1000 + return router + + function startStreaming(channel, res) { + var programs = xmltv.readXMLPrograms() + var startPos = -1 + var programIndex = -1 + var channelExists = false + for (var i = 0; i < programs.length; i++) { + var date = new Date() + if (programs[i].channel == channel) { + channelExists = true + if (programs[i].start <= date && programs[i].stop >= date) { + startPos = date.getTime() - programs[i].start.getTime() programIndex = i break } } - for (var i = programIndex; i < programs.length; i++) - if (programs[i].channel == req.query.channel) - args.push(programs[i].video) - - if (args.length == 0) - return res.status(422).send("Channel not found") - - var vlcPort = config.PORT + streams.length + 1 - - args.push("--start-time=" + startPos) - if (programs.optimized) - args.push(`--sout=#http{mux=ts,dst=:${vlcPort}/}`) - else - args.push(`--sout=#${config.VLC_TRANSCODE_SETTINGS}:http{mux=ts,dst=:${vlcPort}/}`) - if (config.VLC_HIDDEN) - args.push("--intf=dummy") - - - var vlcExe = spawn(config.VLC_EXECUTABLE, args) - var stream = { vlcExe: vlcExe, channel: req.query.channel, viewers: 1, port: vlcPort } - streamIndex = streams.length - streams.push(stream) - setTimeout(() => { - request(`http://${config.HOST}:${vlcPort}/`).on('error', function (err) {/* ignore errors */}).pipe(res) - }, config.VLC_STARTUP_DELAY) } - - res.on('close', () => { - streams[streamIndex].viewers-- - if (streams[streamIndex].viewers == 0) { - streams[streamIndex].vlcExe.kill() - streams.splice(streamIndex, 1) + // End session if any errors. + if (!channelExists) { + inUse = false + res.status(403).send(`Error: Channel doesn't exist. Channel: ${channel}`) + return + } + if (programIndex === -1) { + inUse = false + res.status(403).send(`Error: No scheduled programming available. Channel: ${channel}`) + return + } + if (startPos === -1) { + inUse = false + res.status(403).send(`Error: How the fuck did you get here?. Channel: ${channel}`) + return + } + // Query plex for current program + client.Get(programs[programIndex].key, (result) => { + if (result.err) { + inUse = false + res.status(403).send(`Error: Failed to fetch program info from Plex`) + return } - }) - }) + var fetchedItem = result.result.MediaContainer.Metadata[0] + // Transcode it + client.Transcode(fetchedItem, startPos, (result) => { + if (result.err) { + inUse = false + res.status(403).send(`Error: Failed to add program to playQueue`) + return + } + // Update server timeline every 10 seconds + var stream = result.result + var msElapsed = startPos + var timelineInterval = setInterval(() => { + stream.update(msElapsed) + msElapsed += 10000 + }, 10000) + var args = [ + stream.url, + `--start-time=${(startPos + config.VLC_OPTIONS.DELAY) / 1000}`, + `--sout=#http{mux=ts,dst=:${config.VLC_OPTIONS.PORT}/}` + ] + if (config.VLC_OPTIONS.HIDDEN) + args.push("--intf=dummy") + // Fire up VLC + var vlc = spawn(config.VLC_OPTIONS.PATH, args) + // Wait for VLC to open before we request anything. + setTimeout(() => { + request(`http://${config.HOST}:${config.VLC_OPTIONS.PORT}/`) + .on('error', (err) => { + vlc.kill() + if (err.code === 'ECONNRESET') { + var end = programs[programIndex].stop + var now = new Date() + var timeUntilDone = end.valueOf() - now.valueOf() + timeUntilDone = timeUntilDone > 0 ? timeUntilDone : 0 + setTimeout(() => { + res.removeListener('close', httpEnd) + startStreaming(channel, res) + }, timeUntilDone) + } + }) + .on('data', (chunk) => { + res.write(chunk) + }) + .on("complete", () => { + vlc.kill() + var end = programs[programIndex].stop + var now = new Date() + var timeUntilDone = end.valueOf() - now.valueOf() + timeUntilDone = timeUntilDone > 0 ? timeUntilDone : 0 + setTimeout(() => { + res.removeListener('close', httpEnd) + startStreaming(channel, res) + }, timeUntilDone) + }) + }, config.VLC_OPTIONS.DELAY) + + // When the http session ends: kill vlc + var httpEnd = function () { + vlc.kill() + inUse = false + } + res.on('close', httpEnd) - return router + vlc.on('close', (code) => { + clearInterval(timelineInterval) + stream.stop() + if (code !== 0 && !res.headersSent) { + res.status(400).send(`Error: VLC closed unexpectedly`) + } + }) + }) + }) + + } } \ No newline at end of file diff --git a/src/xmltv.js b/src/xmltv.js index 9402c60..b4209c5 100644 --- a/src/xmltv.js +++ b/src/xmltv.js @@ -3,8 +3,15 @@ const XMLReader = require('xml-reader') const fs = require('fs') const config = require('config-yml') +module.exports = { + WriteXMLTV: WriteXMLTV, + UpdateXMLTV: UpdateXMLTV, + readXMLPrograms: readXMLPrograms, + readXMLChannels: readXMLChannels +} + function readXMLPrograms() { - var data = fs.readFileSync(config.XMLTV_OUTPUT) + var data = fs.readFileSync(config.XMLTV_FILE) var xmltv = XMLReader.parseSync(data.toString()) var programs = [] var tv = xmltv.children @@ -15,16 +22,42 @@ function readXMLPrograms() { channel: tv[i].attributes.channel, start: createDate(tv[i].attributes.start), stop: createDate(tv[i].attributes.stop), - video: tv[i].attributes.video, - optimized: tv[i].attributes.optimized == "true" ? true : false + key: tv[i].attributes['plex-key'] } programs.push(program) } return programs } +function readXMLChannels() { + var data = fs.readFileSync(config.XMLTV_FILE) + var xmltv = XMLReader.parseSync(data.toString()) + var channels = [] + var tv = xmltv.children + for (var i = 0; i < tv.length; i++) { + if (tv[i].name == 'programme') + continue; + //console.log(tv[i]) + var channel = { + channel: tv[i].attributes.id, + shuffle: tv[i].attributes.shuffle + } + for (var y = 0; y < tv[i].children.length; y++) + { + if (tv[i].children[y].name === 'display-name') { + channel.name = tv[i].children[y].children[0].value + } + if (tv[i].children[y].name === 'icon') { + channel.icon = tv[i].children[y].attributes.src + } + } + channels.push(channel) + } + return channels +} + function WriteXMLTV(channels, cb) { - var xw = new XMLWriter(true); + var xw = new XMLWriter(true) var time = new Date() // Build XMLTV and M3U files xw.startDocument() @@ -32,57 +65,45 @@ function WriteXMLTV(channels, cb) { xw.startElement('tv') xw.writeAttribute('generator-info-name', 'psuedotv-plex') writeChannels(xw, channels) - // Programmes + // For each channel for (var i = 0; i < channels.length; i++) { var future = new Date() future.setHours(time.getHours() + config.EPG_CACHE) var tempDate = new Date(time.valueOf()) - while (tempDate < future && channels[i].playlist.length > 0) { - for (var y = 0; y < channels[i].playlist.length; y++) { + // Loop items until EPG_CACHE is satisfied, starting time of first show is NOW. + while (tempDate < future && channels[i].items.length > 0) { + for (var y = 0; y < channels[i].items.length && tempDate < future; y++) { var stopDate = new Date(tempDate.valueOf()) - stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].playlist[y].duration) - var plexURL = channels[i].playlist[y].Media[0].Part[0].key - var optimizedForStreaming = false - for (var z = 0; z < channels[i].playlist[y].Media.length; z++) { - var part = channels[i].playlist[y].Media[z].Part[0] - if (typeof part.optimizedForStreaming !== 'undefined' && part.optimizedForStreaming) { - plexURL = part.key - optimizedForStreaming = part.optimizedForStreaming - break; - } - } - plexURL = `http://${config.PLEX_OPTIONS.hostname}:${config.PLEX_OPTIONS.port}${plexURL}?X-Plex-Token=${config.PLEX_OPTIONS.token}` + stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].items[y].duration) var program = { - info: channels[i].playlist[y], + info: channels[i].items[y], channel: channels[i].channel, start: new Date(tempDate.valueOf()), - stop: stopDate, - plexURL: plexURL, - optimizedForStreaming: optimizedForStreaming.toString() + stop: stopDate } writeProgramme(xw, program) - tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].playlist[y].duration) + tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].items[y].duration) } } } // End TV xw.endElement() xw.endDocument() - fs.writeFileSync(config.XMLTV_OUTPUT, xw.toString()) + fs.writeFileSync(config.XMLTV_FILE, xw.toString()) if (typeof cb == 'function') cb() } function UpdateXMLTV(channels, cb) { var xw = new XMLWriter(true) - var data = fs.readFileSync(config.XMLTV_OUTPUT) + var data = fs.readFileSync(config.XMLTV_FILE) var xml = XMLReader.parseSync(data.toString()) var time = new Date() xw.startDocument() xw.startElement('tv') xw.writeAttribute('generator-info-name', 'psuedotv-plex') writeChannels(xw, channels) - // Programmes + // Foreach channel for (var i = 0; i < channels.length; i++) { // get non-expired programmes for channel var validPrograms = [] @@ -94,97 +115,67 @@ function UpdateXMLTV(channels, cb) { } } // If Channel doesnt exists.. - if (validPrograms.length == 0) { - // write out programs from plex + if (validPrograms.length === 0) { var future = new Date() future.setHours(time.getHours() + config.EPG_CACHE) var tempDate = new Date(time.valueOf()) + // Loop items until EPG_CACHE is satisfied, starting time of first show is NOW. while (tempDate < future) { - for (var y = 0; y < channels[i].playlist.length; y++) { // foreach item in playlist + for (var y = 0; y < channels[i].items.length && tempDate < future; y++) { // foreach item in playlist var stopDate = new Date(tempDate.valueOf()) - stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].playlist[y].duration) - var plexURL = channels[i].playlist[y].Media[0].Part[0].key - var optimizedForStreaming = false - for (var z = 0; z < channels[i].playlist[y].Media.length; z++) { // get optimed video if there is one - var part = channels[i].playlist[y].Media[z].Part[0] - if (typeof part.optimizedForStreaming !== 'undefined' && part.optimizedForStreaming) { - plexURL = part.key - optimizedForStreaming = part.optimizedForStreaming - break; - } - } - plexURL = `http://${config.PLEX_OPTIONS.hostname}:${config.PLEX_OPTIONS.port}${plexURL}?X-Plex-Token=${config.PLEX_OPTIONS.token}` + stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].items[y].duration) var program = { - info: channels[i].playlist[y], + info: channels[i].items[y], channel: channels[i].channel, start: new Date(tempDate.valueOf()), - stop: stopDate, - plexURL: plexURL, - optimizedForStreaming: optimizedForStreaming.toString() + stop: stopDate } writeProgramme(xw, program) - tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].playlist[y].duration) + tempDate.setMilliseconds(tempDate.getMilliseconds() + channels[i].items[y].duration) } } - } else { - var playlistStartIndex = 0 - var isFirstItemFound = false + } else { // Otherwise the channel already exists.. + var playlistStartIndex = -1 var startingDate = new Date(time.valueOf()) var endDate = new Date(time.valueOf()) endDate.setHours(endDate.getHours() + config.EPG_CACHE) // rewrite first valid xml programmes, if it still exists in the plex playlist.. - for (var z = 0; z < channels[i].playlist.length; z++) { - if (channels[i].playlist[z].guid == validPrograms[0].attributes.guid) { - - isFirstItemFound = true + for (var z = 0; z < channels[i].items.length; z++) { + if (channels[i].items[z].key == validPrograms[0].attributes['plex-key']) { playlistStartIndex = z var program = { channel: validPrograms[0].attributes.channel, start: createDate(validPrograms[0].attributes.start), stop: createDate(validPrograms[0].attributes.stop), - plexURL: validPrograms[0].attributes.video, - optimizedForStreaming: validPrograms[0].attributes.optimized, - info: channels[i].playlist[z] + info: channels[i].items[z] } startingDate = new Date(program.stop.valueOf()) writeProgramme(xw, program) break; } } - if (isFirstItemFound) { + if (playlistStartIndex !== -1) { playlistStartIndex++ - if (channels[i].playlist.length == playlistStartIndex) + if (playlistStartIndex === channels[i].items.length) playlistStartIndex = 0 + } else { + playlistStartIndex = 0 } - // write programs from plex, starting at the live playlist index. while (startingDate < endDate) { - for (var y = playlistStartIndex; y < channels[i].playlist.length; y++) { + for (var y = playlistStartIndex; y < channels[i].items.length && startingDate < endDate; y++) { var stopDate = new Date(startingDate.valueOf()) - stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].playlist[y].duration) - var plexURL = channels[i].playlist[y].Media[0].Part[0].key - var optimizedForStreaming = false - for (var z = 0; z < channels[i].playlist[y].Media.length; z++) { - var part = channels[i].playlist[y].Media[z].Part[0] - if (typeof part.optimizedForStreaming !== 'undefined' && part.optimizedForStreaming) { - plexURL = part.key - optimizedForStreaming = part.optimizedForStreaming - break; - } - } - plexURL = `http://${config.PLEX_OPTIONS.hostname}:${config.PLEX_OPTIONS.port}${plexURL}?X-Plex-Token=${config.PLEX_OPTIONS.token}` + stopDate.setMilliseconds(stopDate.getMilliseconds() + channels[i].items[y].duration) var program = { - info: channels[i].playlist[y], + info: channels[i].items[y], channel: channels[i].channel, start: new Date(startingDate.valueOf()), - stop: stopDate, - plexURL: plexURL, - optimizedForStreaming: optimizedForStreaming.toString() + stop: stopDate } writeProgramme(xw, program) - startingDate.setMilliseconds(startingDate.getMilliseconds() + channels[i].playlist[y].duration) - playlistStartIndex = 0 + startingDate.setMilliseconds(startingDate.getMilliseconds() + channels[i].items[y].duration) } + playlistStartIndex = 0 } } } @@ -192,23 +183,17 @@ function UpdateXMLTV(channels, cb) { xw.endElement() // End Doc xw.endDocument() - fs.writeFileSync(config.XMLTV_OUTPUT, xw.toString()) + fs.writeFileSync(config.XMLTV_FILE, xw.toString()) if (typeof cb == 'function') cb() } - - -module.exports = { - WriteXMLTV: WriteXMLTV, - UpdateXMLTV: UpdateXMLTV, - readXMLPrograms: readXMLPrograms -} function writeChannels(xw, channels) { // Channels for (var i = 0; i < channels.length; i++) { xw.startElement('channel') xw.writeAttribute('id', channels[i].channel) + xw.writeAttribute('shuffle', channels[i].shuffle ? 'yes': 'no') xw.startElement('display-name') xw.writeAttribute('lang', 'en') xw.text(channels[i].name) @@ -221,16 +206,14 @@ function writeChannels(xw, channels) { xw.endElement() } } + function writeProgramme(xw, program) { // Programme xw.startElement('programme') xw.writeAttribute('start', createXMLTVDate(program.start)) xw.writeAttribute('stop', createXMLTVDate(program.stop)) xw.writeAttribute('channel', program.channel) - // For VLC to handle... - xw.writeAttribute('video', program.plexURL) - xw.writeAttribute('optimized', program.optimizedForStreaming) - xw.writeAttribute('guid', program.info.guid) + xw.writeAttribute('plex-key', program.info.key) // Used to link this programme to Plex.. // Title xw.startElement('title') xw.writeAttribute('lang', 'en') @@ -255,9 +238,9 @@ function writeProgramme(xw, program) { // Icon xw.startElement('icon') if (program.info.type == 'movie') - xw.writeAttribute('src', 'http://' + config.PLEX_OPTIONS.hostname + ':' + config.PLEX_OPTIONS.port + program.info.thumb + '?X-Plex-Token=' + config.PLEX_OPTIONS.token) + xw.writeAttribute('src', 'http://' + config.PLEX_OPTIONS.hostname + ':' + config.PLEX_OPTIONS.port + program.info.thumb) else if (program.info.type == 'episode') - xw.writeAttribute('src', 'http://' + config.PLEX_OPTIONS.hostname + ':' + config.PLEX_OPTIONS.port + program.info.parentThumb + '?X-Plex-Token=' + config.PLEX_OPTIONS.token) + xw.writeAttribute('src', 'http://' + config.PLEX_OPTIONS.hostname + ':' + config.PLEX_OPTIONS.port + program.info.parentThumb) xw.endElement() // Desc xw.startElement('desc')